This is a personal torrent client, not a web client or daemon, so the rarely-used HTTP-client knobs and redundant tuning flags are gone — flag, config field, implementation, and tests removed for each. The option surface drops from 54 to 28. Web-client cluster removed: --all-proxy (HTTP_PROXY env still works), load/save-cookies, --referer, -U/--user-agent, --http-user/--http-passwd, --conditional-get, --remote-time, --check-certificate, --ca-certificate, --lowest-speed-limit. Pass --header for a one-off auth/cookie/UA header. Redundant knobs removed: -x and -k (folded into -s), --connect-timeout, --retry-wait, per-item --max-download-limit/-u, --bt-max-peers, --bt-stop-timeout, --disable-ipv6, --auto-save-interval, --human-readable, --stop, -T (a positional .torrent works). follow-torrent and human-readable are now unconditional defaults.
780 lines
25 KiB
Go
780 lines
25 KiB
Go
// Command got is a Rob-Pike-style command-line download tool: a small
|
|
// multi-protocol downloader. It downloads HTTP(S) URLs over several connections
|
|
// and BitTorrent magnets/torrents, with a comprehensive set of command-line
|
|
// options. Each download runs as one goroutine; a scheduler bounds how many run
|
|
// at once; a single goroutine draws the progress line.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/anacrolix/torrent"
|
|
"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"
|
|
"github.com/hanbok/got/progress"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:]))
|
|
}
|
|
|
|
func run(args []string) int {
|
|
res, err := cli.Parse(args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
return 1
|
|
}
|
|
switch res.Action {
|
|
case cli.ShowVersion:
|
|
cli.PrintVersion(os.Stdout)
|
|
return 0
|
|
case cli.ShowHelp:
|
|
cli.PrintHelp(os.Stdout, res.HelpTag)
|
|
return 0
|
|
}
|
|
|
|
opts := res.Options
|
|
|
|
// --checksum is validated up front so a bad type or digest aborts before any
|
|
// download, rejecting it at option-parse time rather than failing mid-run
|
|
// (and once, not once per HTTP download that shares the spec).
|
|
if cs := opts.Str("checksum"); cs != "" {
|
|
if err := httpdl.ValidChecksum(cs); err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
targets := gatherURIs(opts, res.URIs)
|
|
if len(targets) == 0 {
|
|
fmt.Fprintln(os.Stderr, "got: no URI, torrent or magnet given (try --help)")
|
|
return 1
|
|
}
|
|
|
|
if opts.Bool("show-files") {
|
|
return showFiles(allURIs(targets))
|
|
}
|
|
|
|
jobs, btClient, err := build(opts, targets)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
return 1
|
|
}
|
|
if btClient != nil {
|
|
defer btClient.Close()
|
|
}
|
|
|
|
// A single registered signal channel handles both presses: the first cancels
|
|
// the graceful context, the second saves the session best-effort and forces
|
|
// exit. Using one channel avoids a startup race where two separate handlers
|
|
// could split the two presses and need a third to actually quit.
|
|
sessionFile := opts.Str("save-session")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var jobsRef atomic.Pointer[[]job]
|
|
// publish snapshots the slice header (j is a by-value copy) and stores a
|
|
// pointer to it, so the signal handler can read the current jobs lock-free
|
|
// without racing the `jobs = append(...)` reassignment in the follow pass.
|
|
publish := func(j []job) { jobsRef.Store(&j) }
|
|
go handleSignals(cancel, sessionFile, &jobsRef)
|
|
|
|
eng := download.NewEngine(opts.Int("max-concurrent-downloads"))
|
|
|
|
uiCtx, uiCancel := context.WithCancel(context.Background())
|
|
uiDone := make(chan struct{})
|
|
if !opts.Bool("quiet") && !opts.Bool("dry-run") {
|
|
rep := progress.New(eng.Snapshot, true)
|
|
go func() {
|
|
rep.Run(uiCtx)
|
|
close(uiDone)
|
|
}()
|
|
} else {
|
|
close(uiDone)
|
|
}
|
|
|
|
publish(jobs)
|
|
results := runJobs(ctx, eng, jobs)
|
|
|
|
// A .torrent fetched over HTTP becomes a BitTorrent download of its content,
|
|
// done as a second pass so the engine and scheduler stay simple.
|
|
if ctx.Err() == nil && !opts.Bool("dry-run") {
|
|
if follow := followUps(jobs, btClient, btOptions(opts)); len(follow) > 0 {
|
|
// The engine numbers each Result by its position in the slice it was
|
|
// passed, so the follow pass restarts at Index 0. Offset those by the
|
|
// initial job count to give the concatenated slice one contiguous
|
|
// Index space, so the sort below restores input order deterministically.
|
|
nInitial := len(jobs)
|
|
jobs = append(jobs, follow...)
|
|
publish(jobs)
|
|
followResults := runJobs(ctx, eng, follow)
|
|
for i := range followResults {
|
|
followResults[i].Index += nInitial
|
|
}
|
|
results = append(results, followResults...)
|
|
}
|
|
}
|
|
|
|
uiCancel()
|
|
<-uiDone // wait for the renderer's final frame before printing the OK/FAIL lines
|
|
|
|
if sessionFile != "" {
|
|
if err := saveSession(sessionFile, jobs); err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
}
|
|
}
|
|
// Restore input order so the OK/FAIL lines and the exit code are
|
|
// deterministic regardless of which download finished first.
|
|
sort.Slice(results, func(i, j int) bool { return results[i].Index < results[j].Index })
|
|
if opts.Bool("dry-run") {
|
|
return reportDryRun(results)
|
|
}
|
|
return report(results)
|
|
}
|
|
|
|
// job pairs a download with the source string that produced it, so we can write
|
|
// the still-unfinished ones back to a session file.
|
|
type job struct {
|
|
source string
|
|
dl download.Download
|
|
}
|
|
|
|
// build turns each URI into a Download, classified by scheme, and creates the
|
|
// one shared BitTorrent client the run needs (nil if no torrents are involved).
|
|
// The overall speed limits are one shared limiter each, applied to both HTTP
|
|
// downloads and the BitTorrent client so the cap is truly global.
|
|
// errDuplicate marks a source that names the same torrent as an earlier one.
|
|
// exitCode maps it to the duplicate-infohash exit code (12) via errors.Is.
|
|
var errDuplicate = errors.New("duplicate download")
|
|
|
|
// failedDownload is a Download that does no work and fails immediately with a
|
|
// fixed error. build() uses it for a duplicate torrent source, so the duplicate
|
|
// is reported as a failed download without ever obtaining — or Dropping — a
|
|
// torrent on the shared client.
|
|
type failedDownload struct {
|
|
name string
|
|
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 {
|
|
return download.Stat{Name: f.name, Status: download.Errored, Total: -1}
|
|
}
|
|
|
|
// seenInfoHash records source under its torrent infohash in seen and reports
|
|
// whether that infohash was already recorded, returning the source that first
|
|
// claimed it. The shared client keys torrents by infohash and does not refcount,
|
|
// so two sources naming one torrent would have the first to finish Drop it out
|
|
// from under the other; callers fail the duplicate instead. A source whose
|
|
// infohash can't be known without a network fetch (an HTTP URL), or that is
|
|
// v2-only or unreadable, is never a duplicate: it isn't recorded, so it runs.
|
|
func seenInfoHash(seen map[metainfo.Hash]string, source string, isFile bool) (first string, dup bool) {
|
|
h, ok := bt.SourceInfoHash(source, isFile)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if first, dup = seen[h]; dup {
|
|
return first, true
|
|
}
|
|
seen[h] = source
|
|
return "", false
|
|
}
|
|
|
|
// dedupeTorrents finds torrent/magnet sources that repeat an earlier source's
|
|
// infohash, returning each duplicate URI mapped to the source it repeats.
|
|
func dedupeTorrents(uris []string) map[string]string {
|
|
seen := map[metainfo.Hash]string{}
|
|
dup := map[string]string{}
|
|
for _, u := range uris {
|
|
k := uriKind(u)
|
|
if k != kindMagnet && k != kindTorrent {
|
|
continue
|
|
}
|
|
if first, isDup := seenInfoHash(seen, u, k == kindTorrent); isDup {
|
|
dup[u] = first
|
|
}
|
|
}
|
|
return dup
|
|
}
|
|
|
|
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)
|
|
// would share one torrent in the client, which does not refcount: the first to
|
|
// finish would Drop it out from under the other. The duplicate is reported as
|
|
// a failed download (duplicate infohash, exit 12); the loop below does this,
|
|
// so the duplicate never obtains or Drops a shared torrent.
|
|
dups := dedupeTorrents(flat)
|
|
|
|
overallDL := makeLimiter(opts.Size("max-overall-download-limit"))
|
|
overallUL := makeLimiter(opts.Size("max-overall-upload-limit"))
|
|
// --out names a single output file, so it only applies when exactly one HTTP
|
|
// download (one mirror group) is queued; warn rather than silently misname.
|
|
nhttp := countHTTP(targets)
|
|
single := nhttp == 1
|
|
if nhttp > 1 && opts.Str("out") != "" {
|
|
fmt.Fprintln(os.Stderr, "got: --out ignored: it applies only to a single HTTP download")
|
|
}
|
|
hc := httpConfig(opts, single, overallDL)
|
|
bo := btOptions(opts)
|
|
|
|
// One client serves every torrent, shared across the session. A failure to start
|
|
// it (e.g. the listen port is taken) disables BitTorrent but still lets HTTP
|
|
// downloads run; those torrents then report the error.
|
|
var client *torrent.Client
|
|
if needsBT(flat) {
|
|
c, err := bt.NewClient(btClientConfig(opts, overallDL, overallUL))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "got: bittorrent disabled:", err)
|
|
} else {
|
|
client = c
|
|
}
|
|
}
|
|
|
|
var out []job
|
|
for _, t := range targets {
|
|
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{
|
|
name: u,
|
|
err: fmt.Errorf("%w: same torrent as %s", errDuplicate, first),
|
|
}})
|
|
continue
|
|
}
|
|
var dl download.Download
|
|
switch uriKind(u) {
|
|
case kindMagnet:
|
|
dl = bt.New(client, u, false, bo)
|
|
case kindHTTP, kindFollow:
|
|
dl = httpdl.New(t.uris, hc) // every mirror URL for this one file
|
|
case kindTorrent:
|
|
dl = bt.New(client, u, true, bo)
|
|
default:
|
|
if client != nil {
|
|
client.Close()
|
|
}
|
|
return nil, nil, fmt.Errorf("unsupported URI: %s", u)
|
|
}
|
|
out = append(out, job{source: src, dl: dl})
|
|
}
|
|
return out, client, nil
|
|
}
|
|
|
|
// uriKind classifies a URI by scheme into the taxonomy build() and needsBT()
|
|
// both switch on. Scheme wins over suffix: an http URL ending in .torrent is
|
|
// fetched over HTTP first, then followed as a torrent (kindFollow).
|
|
func uriKind(u string) kind {
|
|
lu := strings.ToLower(u)
|
|
switch {
|
|
case strings.HasPrefix(u, "magnet:"):
|
|
return kindMagnet
|
|
case strings.HasPrefix(u, "http://"), strings.HasPrefix(u, "https://"):
|
|
if strings.HasSuffix(lu, ".torrent") {
|
|
return kindFollow
|
|
}
|
|
return kindHTTP
|
|
case strings.HasSuffix(lu, ".torrent"):
|
|
return kindTorrent
|
|
}
|
|
return kindUnsupported
|
|
}
|
|
|
|
type kind int
|
|
|
|
const (
|
|
kindUnsupported kind = iota
|
|
kindMagnet // magnet: link, a BitTorrent download
|
|
kindHTTP // plain HTTP(S) download
|
|
kindTorrent // local .torrent file, a BitTorrent download
|
|
kindFollow // HTTP(S) .torrent fetched, then followed as BitTorrent
|
|
)
|
|
|
|
// needsBT reports whether the run will involve any torrent, so we only start a
|
|
// client when one is actually needed.
|
|
func needsBT(uris []string) bool {
|
|
for _, u := range uris {
|
|
switch uriKind(u) {
|
|
case kindMagnet, kindTorrent, kindFollow:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// countHTTP counts HTTP download targets. A mirror group is one output file, so
|
|
// it counts groups, not URLs, for the --out single-file decision.
|
|
func countHTTP(targets []target) int {
|
|
n := 0
|
|
for _, t := range targets {
|
|
u := t.uris[0]
|
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// runJobs runs a set of jobs through the engine and returns their results.
|
|
func runJobs(ctx context.Context, eng *download.Engine, jobs []job) []download.Result {
|
|
dls := make([]download.Download, len(jobs))
|
|
for i := range jobs {
|
|
dls[i] = jobs[i].dl
|
|
}
|
|
return eng.Run(ctx, dls)
|
|
}
|
|
|
|
// followUps turns each completed HTTP .torrent download into a BitTorrent
|
|
// download of its content. A file is followed only if it parses as a torrent,
|
|
// 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 {
|
|
continue
|
|
}
|
|
// j.source is the TAB-joined mirror group; the primary (uris[0]) is what
|
|
// build() classified and named the download on, so test its suffix.
|
|
primary, _, _ := strings.Cut(j.source, "\t")
|
|
if !strings.HasSuffix(strings.ToLower(primary), ".torrent") {
|
|
continue
|
|
}
|
|
path := h.Path()
|
|
if _, err := bt.Files(path); err != nil {
|
|
continue // downloaded file is not a valid torrent
|
|
}
|
|
// build() dedupes pass-1 sources but cannot reach a not-yet-fetched HTTP
|
|
// .torrent, so dedupe the fetched ones here too against the same hazard.
|
|
if first, dup := seenInfoHash(seen, path, true); 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
|
|
}
|
|
|
|
// target is one download: a single torrent/magnet/URL, or several HTTP mirror
|
|
// URLs that serve identical bytes (uris[0] is the primary).
|
|
type target struct{ uris []string }
|
|
|
|
// allURIs flattens targets to a plain URL list for the per-URL helpers
|
|
// (dedupe, needsBT, show-files) that don't care about grouping.
|
|
func allURIs(ts []target) []string {
|
|
var u []string
|
|
for _, t := range ts {
|
|
u = append(u, t.uris...)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// gatherURIs collects download targets from the command line 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, 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
|
|
seq := opts.Bool("force-sequential")
|
|
var httpGroup []string
|
|
for _, u := range cmdURIs {
|
|
// 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) == kindHTTP && !seq {
|
|
httpGroup = append(httpGroup, u)
|
|
} else {
|
|
ts = append(ts, target{uris: []string{u}})
|
|
}
|
|
}
|
|
if len(httpGroup) > 0 {
|
|
ts = append(ts, target{uris: httpGroup})
|
|
}
|
|
if in := opts.Str("input-file"); in != "" {
|
|
ts = append(ts, readInputFile(in)...)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// readInputFile parses an input file: one download per line, and TAB-separated
|
|
// URIs on a line are mirrors of that one download. Blank lines and '#' comments
|
|
// are skipped.
|
|
func readInputFile(path string) []target {
|
|
f := os.Stdin
|
|
if path != "-" {
|
|
var err error
|
|
if f, err = os.Open(path); err != nil {
|
|
// A missing input file is fine: it just means "nothing to resume
|
|
// yet", which is what the load+save-session idiom needs on first run.
|
|
if !os.IsNotExist(err) {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
}
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
}
|
|
var ts []target
|
|
sc := bufio.NewScanner(f)
|
|
// Magnet links with many trackers (and long signed URLs) can exceed the
|
|
// 64KB default token size, so raise the cap.
|
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "#") {
|
|
continue
|
|
}
|
|
var g []string
|
|
for _, field := range strings.Split(line, "\t") {
|
|
if field = strings.TrimSpace(field); field != "" {
|
|
g = append(g, field)
|
|
}
|
|
}
|
|
if len(g) > 0 {
|
|
ts = append(ts, target{uris: g})
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
func httpConfig(opts *cli.Options, single bool, overallDL *rate.Limiter) httpdl.Config {
|
|
out := ""
|
|
if single {
|
|
out = opts.Str("out")
|
|
}
|
|
return httpdl.Config{
|
|
Dir: opts.Str("dir"),
|
|
Out: out,
|
|
Split: opts.Int("split"),
|
|
Tries: opts.Int("max-tries"),
|
|
Timeout: time.Duration(opts.Int("timeout")) * time.Second,
|
|
FileAlloc: opts.Str("file-allocation"),
|
|
Continue: opts.Bool("continue"),
|
|
AllowOverwrite: opts.Bool("allow-overwrite"),
|
|
AutoRename: opts.Bool("auto-file-renaming"),
|
|
Headers: opts.List("header"),
|
|
OverallLimiter: overallDL,
|
|
Checksum: opts.Str("checksum"),
|
|
DryRun: opts.Bool("dry-run"),
|
|
}
|
|
}
|
|
|
|
// btClientConfig is the run-wide configuration for the one shared torrent client.
|
|
func btClientConfig(opts *cli.Options, overallDL, overallUL *rate.Limiter) bt.ClientConfig {
|
|
return bt.ClientConfig{
|
|
Dir: opts.Str("dir"),
|
|
ListenPortSpec: opts.Str("listen-port"),
|
|
DHT: opts.Bool("enable-dht"),
|
|
OverallDown: overallDL,
|
|
OverallUp: overallUL,
|
|
}
|
|
}
|
|
|
|
// btOptions is the per-torrent configuration.
|
|
func btOptions(opts *cli.Options) bt.Options {
|
|
return bt.Options{
|
|
SeedTimeSet: opts.IsSet("seed-time"),
|
|
SeedTime: time.Duration(opts.Float("seed-time") * float64(time.Minute)),
|
|
SeedRatio: opts.Float("seed-ratio"),
|
|
SelectFiles: parseSelect(opts.Str("select-file")),
|
|
CheckIntegrity: opts.Bool("check-integrity"),
|
|
DryRun: opts.Bool("dry-run"),
|
|
}
|
|
}
|
|
|
|
// showFiles prints the file list of each torrent argument and returns an exit
|
|
// code. Magnets need their metadata fetched first, which we don't do here.
|
|
func showFiles(uris []string) int {
|
|
printed := false
|
|
for _, u := range uris {
|
|
if !strings.HasSuffix(strings.ToLower(u), ".torrent") {
|
|
if strings.HasPrefix(u, "magnet:") {
|
|
fmt.Printf("%s: --show-files needs a .torrent (magnet metadata is fetched at download time)\n", u)
|
|
}
|
|
continue
|
|
}
|
|
files, err := bt.Files(u)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("%s:\n", u)
|
|
for _, f := range files {
|
|
fmt.Printf(" %d %s (%d bytes)\n", f.Index, f.Path, f.Length)
|
|
}
|
|
printed = true
|
|
}
|
|
if !printed {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// reportDryRun prints what --dry-run learned about each source — that it is
|
|
// reachable and how big it is — none of it having been downloaded, and returns
|
|
// the exit code: 0 if every source was reachable, else the dominant error's
|
|
// code (so a 404 under --dry-run still exits 3, a refused host 6, etc.).
|
|
func reportDryRun(results []download.Result) int {
|
|
var lastReal error
|
|
for _, r := range results {
|
|
switch {
|
|
case r.Err == nil:
|
|
if r.Stat.Total >= 0 {
|
|
fmt.Printf("DRY %s (%d bytes)\n", r.Name, r.Stat.Total)
|
|
} else {
|
|
fmt.Printf("DRY %s (size unknown)\n", r.Name)
|
|
}
|
|
case errors.Is(r.Err, context.Canceled):
|
|
fmt.Printf("STOP %s\n", r.Name)
|
|
default:
|
|
fmt.Printf("FAIL %s: %v\n", r.Name, r.Err)
|
|
lastReal = r.Err
|
|
}
|
|
}
|
|
if lastReal != nil {
|
|
return exitCode(lastReal)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// report prints a one-line outcome per download and returns the process exit
|
|
// code, mapped to the documented exit-status table:
|
|
// 0 all OK, 7 cancelled with at least one completed, 1 cancelled with nothing
|
|
// completed, else the code of the dominant (last) real error, mapped to an
|
|
// exit code by exitCode.
|
|
func report(results []download.Result) int {
|
|
completed, cancelled := 0, 0
|
|
var lastReal error
|
|
for _, r := range results {
|
|
switch {
|
|
case r.Err == nil:
|
|
fmt.Printf("OK %s\n", r.Name)
|
|
completed++
|
|
case errors.Is(r.Err, context.Canceled):
|
|
fmt.Printf("STOP %s (interrupted%s)\n", r.Name, progressNote(r.Stat))
|
|
cancelled++
|
|
default:
|
|
fmt.Printf("FAIL %s: %v\n", r.Name, r.Err)
|
|
lastReal = r.Err
|
|
}
|
|
}
|
|
|
|
// Exit code 7 is returned when the only failures were interruptions and at
|
|
// least one download finished: unfinished downloads remained in the queue when
|
|
// the process exited by pressing Ctrl-C. With no real error and nothing
|
|
// complete, fall back to 1.
|
|
if lastReal == nil {
|
|
switch {
|
|
case cancelled > 0 && completed > 0:
|
|
return 7
|
|
case cancelled > 0:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
return exitCode(lastReal)
|
|
}
|
|
|
|
// exitCode maps a real (non-cancellation) download error to the documented
|
|
// exit-status codes. The retry path wraps the underlying cause with %w, so we can inspect the
|
|
// chain: an existing output file (13) and a 404 (3) are classified by their
|
|
// httpdl sentinel type; name resolution (19), timeout (2), and a connection-level
|
|
// network problem (6) are classified by typed errors with a substring fallback
|
|
// for third-party net text; everything else is the generic unknown error (1).
|
|
// Telling 19 from 6 from 2 apart is the point — it says whether the user's own
|
|
// network, the remote host, or a stall is to blame.
|
|
func exitCode(err error) int {
|
|
switch {
|
|
case errors.Is(err, httpdl.ErrOutputExists):
|
|
return 13
|
|
case isNameResolution(err):
|
|
return 19
|
|
case isTimeout(err):
|
|
return 2
|
|
case isNetwork(err):
|
|
return 6
|
|
case errors.Is(err, httpdl.ErrNotFound):
|
|
return 3
|
|
case errors.Is(err, httpdl.ErrChecksum):
|
|
return 32
|
|
case errors.Is(err, errDuplicate):
|
|
return 12
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// isNameResolution reports whether err is (or wraps) a DNS lookup failure —
|
|
// "name resolution failed" (19), which usually points at the user's own
|
|
// network or resolver rather than a dead remote. The stdlib reports every such
|
|
// failure (no-such-host, server-misbehaving) as *net.DNSError.
|
|
func isNameResolution(err error) bool {
|
|
var de *net.DNSError
|
|
return errors.As(err, &de)
|
|
}
|
|
|
|
// isNetwork reports whether err is a connection-level failure — refused, reset,
|
|
// or an unreachable host/network — a "network problem" (6). A dial error from
|
|
// net/http wraps the syscall errno, so the typed check sees through it.
|
|
func isNetwork(err error) bool {
|
|
return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ECONNRESET) ||
|
|
errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH)
|
|
}
|
|
|
|
// isTimeout reports whether err is (or wraps) a timeout: a deadline exceeded, a
|
|
// net.Error that timed out, or our own idle-read timeout (httpdl.ErrTimeout).
|
|
func isTimeout(err error) bool {
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, httpdl.ErrTimeout) {
|
|
return true
|
|
}
|
|
var ne net.Error
|
|
return errors.As(err, &ne) && ne.Timeout()
|
|
}
|
|
|
|
// saveSession writes the sources of every download that did not finish to path,
|
|
// one per line, so a later `got -i path` resumes them (HTTP via -c, BitTorrent
|
|
// via anacrolix's recheck of existing data). An all-done run leaves the file
|
|
// empty.
|
|
func saveSession(path string, jobs []job) error {
|
|
lines := unfinishedSources(jobs)
|
|
data := ""
|
|
if len(lines) > 0 {
|
|
data = strings.Join(lines, "\n") + "\n"
|
|
}
|
|
// Write to a temp file in the same directory, then rename over the target so
|
|
// a crash or a force-quit mid-write can never leave a half-written (and thus
|
|
// truncated) resume list. The rename is atomic within one filesystem.
|
|
dir := filepath.Dir(path)
|
|
tmp, err := os.CreateTemp(dir, ".got-session-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.WriteString(data); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unfinishedSources returns the source string of each download not in the
|
|
// Complete state.
|
|
func unfinishedSources(jobs []job) []string {
|
|
var out []string
|
|
for _, j := range jobs {
|
|
if j.dl.Stat().Status != download.Complete {
|
|
out = append(out, j.source)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// progressNote describes how far an interrupted download got, so the user knows
|
|
// resuming is worthwhile.
|
|
func progressNote(s download.Stat) string {
|
|
if s.Total > 0 && s.Completed > 0 {
|
|
return fmt.Sprintf("; %d%% done", int(s.Completed*100/s.Total))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// handleSignals owns the one registered signal channel. The first Ctrl-C (or
|
|
// TERM) cancels the graceful context so run() can wind down and save normally;
|
|
// a second forces exit, but first writes the session best-effort so the resume
|
|
// list survives a force-quit.
|
|
func handleSignals(cancel context.CancelFunc, sessionFile string, jobs *atomic.Pointer[[]job]) {
|
|
sig := make(chan os.Signal, 2)
|
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
|
<-sig // first: cancel the graceful context
|
|
cancel()
|
|
<-sig // second: force exit
|
|
if sessionFile != "" {
|
|
var js []job // nil until the first publish; saveSession handles an empty list
|
|
if p := jobs.Load(); p != nil {
|
|
js = *p
|
|
}
|
|
if err := saveSession(sessionFile, js); err != nil {
|
|
fmt.Fprintln(os.Stderr, "got:", err)
|
|
}
|
|
}
|
|
fmt.Fprintln(os.Stderr, "\ngot: forced exit")
|
|
os.Exit(130)
|
|
}
|
|
|
|
func makeLimiter(bps int64) *rate.Limiter {
|
|
if bps <= 0 {
|
|
return nil
|
|
}
|
|
return rate.NewLimiter(rate.Limit(bps), download.LimiterBurst(bps))
|
|
}
|
|
|
|
// maxSelectIndex bounds how far a --select-file range is expanded: a torrent has
|
|
// at most a few thousand files, so a degenerate spec like "1-9999999999" must not
|
|
// spin or balloon the map. Indexes past the real file count are ignored anyway.
|
|
const maxSelectIndex = 1 << 20
|
|
|
|
// parseSelect parses "1,3-5" into a set of 1-based file indexes.
|
|
func parseSelect(s string) map[int]bool {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
out := map[int]bool{}
|
|
for _, part := range strings.Split(s, ",") {
|
|
lo, hi, ok := strings.Cut(part, "-")
|
|
a, _ := strconv.Atoi(strings.TrimSpace(lo))
|
|
if !ok {
|
|
if a > 0 {
|
|
out[a] = true
|
|
}
|
|
continue
|
|
}
|
|
b, _ := strconv.Atoi(strings.TrimSpace(hi))
|
|
if a < 1 {
|
|
a = 1
|
|
}
|
|
if b > maxSelectIndex {
|
|
b = maxSelectIndex
|
|
}
|
|
for i := a; i <= b; i++ {
|
|
out[i] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|