refactor: migrate lint config to v2, remove daemon auto-upgrade, add trust badges
Some checks failed
Release / release (push) Failing after 1s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

This commit is contained in:
Deivid Soto 2026-03-30 23:24:16 +02:00
parent a13104bdb7
commit efa4562acd
18 changed files with 188 additions and 268 deletions

View file

@ -81,7 +81,7 @@ jobs:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.out
files: ./coverage.out
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1,10 +1,11 @@
version: "2"
run:
timeout: 5m
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
@ -16,16 +17,13 @@ linters:
- errname
- errorlint
- exhaustive
- gofmt
- goimports
- misspell
- nilerr
- prealloc
- unconvert
- unparam
- wastedassign
linters-settings:
settings:
gosec:
excludes:
- G104 # Allow unhandled errors in fire-and-forget (notifications)
@ -36,9 +34,14 @@ linters-settings:
default-signifies-exhaustive: true
misspell:
locale: US
issues:
exclude-dirs:
exclusions:
paths:
- dist
formatters:
enable:
- gofmt
- goimports
exclusions:
paths:
- dist
max-issues-per-linter: 50
max-same-issues: 5

View file

@ -3,7 +3,11 @@
> **⚠️ Beta** — unarr is under active development. Features may change, and bugs are expected. [Report issues here](https://github.com/torrentclaw/unarr/issues).
[![CI](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml/badge.svg)](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml)
[![Latest Release](https://img.shields.io/github/v/release/torrentclaw/unarr)](https://github.com/torrentclaw/unarr/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/torrentclaw/unarr)](https://goreportcard.com/report/github.com/torrentclaw/unarr)
[![Coverage](https://img.shields.io/codecov/c/github/torrentclaw/unarr)](https://codecov.io/gh/torrentclaw/unarr)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-scanned-brightgreen?logo=virustotal)](https://github.com/torrentclaw/unarr/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/torrentclaw/unarr)](https://hub.docker.com/r/torrentclaw/unarr)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/unarr)](go.mod)

1
go.mod
View file

@ -16,6 +16,7 @@ require (
github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
)

2
go.sum
View file

@ -533,6 +533,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View file

@ -73,17 +73,6 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil
}
// ReportUpgradeResult reports the outcome of a self-upgrade attempt.
func (c *Client) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
var resp struct {
Success bool `json:"success"`
}
if err := c.doPost(ctx, "/api/internal/agent/upgrade-result", result, &resp); err != nil {
return fmt.Errorf("report upgrade: %w", err)
}
return nil
}
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
@ -93,6 +82,15 @@ func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*Status
return &resp, nil
}
// BatchReportStatus sends multiple status updates in a single request.
func (c *Client) BatchReportStatus(ctx context.Context, updates []StatusUpdate) (*BatchStatusResponse, error) {
var resp BatchStatusResponse
if err := c.doPost(ctx, "/api/internal/agent/status", BatchStatusRequest{Updates: updates}, &resp); err != nil {
return nil, fmt.Errorf("batch report status: %w", err)
}
return &resp, nil
}
// ---------------------------------------------------------------------------
// Usenet endpoints
// ---------------------------------------------------------------------------

View file

@ -324,62 +324,3 @@ func TestHeartbeatWithoutUpgradeSignal(t *testing.T) {
t.Errorf("expected no upgrade signal, got %+v", resp.Upgrade)
}
}
func TestReportUpgradeResult(t *testing.T) {
var received UpgradeResult
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/upgrade-result" {
t.Errorf("path = %s, want /api/internal/agent/upgrade-result", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(struct{ Success bool }{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.ReportUpgradeResult(context.Background(), UpgradeResult{
AgentID: "agent-1",
Success: true,
Version: "2.0.0",
})
if err != nil {
t.Fatalf("ReportUpgradeResult failed: %v", err)
}
if received.AgentID != "agent-1" {
t.Errorf("agentId = %q, want agent-1", received.AgentID)
}
if !received.Success {
t.Error("expected success=true")
}
if received.Version != "2.0.0" {
t.Errorf("version = %q, want 2.0.0", received.Version)
}
}
func TestReportUpgradeResultFailure(t *testing.T) {
var received UpgradeResult
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(struct{ Success bool }{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.ReportUpgradeResult(context.Background(), UpgradeResult{
AgentID: "agent-1",
Success: false,
Error: "checksum mismatch",
})
if err != nil {
t.Fatalf("ReportUpgradeResult failed: %v", err)
}
if received.Success {
t.Error("expected success=false")
}
if received.Error != "checksum mismatch" {
t.Errorf("error = %q, want 'checksum mismatch'", received.Error)
}
}

View file

@ -27,7 +27,6 @@ type Daemon struct {
// Callbacks
OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest)
OnUpgradeRequested func(version string)
OnControlAction func(action, taskID string)
// State
@ -35,13 +34,17 @@ type Daemon struct {
Features FeatureFlags
Info AgentInfo
State DaemonState
upgradeInProgress bool
heartbeatFailures int
lastNotifiedVersion string
// Callbacks for state tracking (set by cmd/daemon.go)
GetActiveCount func() int
GetCleanableBytes func() int64
// Watching tracks whether a user is viewing download progress in the web UI.
// When false, the progress reporter skips detailed updates (only sends final states).
Watching bool
// Exposed tickers for hot-reload
PollTicker *time.Ticker
HeartbeatTicker *time.Ticker
@ -191,20 +194,18 @@ func (d *Daemon) heartbeat(ctx context.Context) {
d.heartbeatFailures = 0
}
// Update state file
// Update watching flag and state file
d.Watching = resp.Watching
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
d.State.ActiveTasks = d.GetActiveCount()
}
WriteState(&d.State)
// Check for upgrade signal from server
if resp.Upgrade != nil && resp.Upgrade.Version != "" && !d.upgradeInProgress {
d.upgradeInProgress = true
log.Printf("Upgrade requested by server: %s → %s", d.cfg.Version, resp.Upgrade.Version)
if d.OnUpgradeRequested != nil {
go d.OnUpgradeRequested(resp.Upgrade.Version)
}
// Log once per version when server suggests an upgrade
if resp.Upgrade != nil && resp.Upgrade.Version != "" && resp.Upgrade.Version != d.lastNotifiedVersion {
d.lastNotifiedVersion = resp.Upgrade.Version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", resp.Upgrade.Version)
}
}
@ -225,12 +226,9 @@ func (d *Daemon) handleEvent(event ServerEvent) {
}
case "upgrade":
if event.Upgrade != nil && event.Upgrade.Version != "" && !d.upgradeInProgress {
d.upgradeInProgress = true
log.Printf("Upgrade requested via WebSocket: %s → %s", d.cfg.Version, event.Upgrade.Version)
if d.OnUpgradeRequested != nil {
go d.OnUpgradeRequested(event.Upgrade.Version)
}
if event.Upgrade != nil && event.Upgrade.Version != "" && event.Upgrade.Version != d.lastNotifiedVersion {
d.lastNotifiedVersion = event.Upgrade.Version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", event.Upgrade.Version)
}
case "control":
@ -253,11 +251,6 @@ func (d *Daemon) TriggerPoll() {
}
}
// ClearUpgradeInProgress resets the upgrade flag so a retry can be attempted.
func (d *Daemon) ClearUpgradeInProgress() {
d.upgradeInProgress = false
}
func (d *Daemon) deregister() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

View file

@ -29,9 +29,6 @@ type Transport interface {
// Deregister notifies the server of graceful shutdown.
Deregister(ctx context.Context, agentID string) error
// ReportUpgradeResult reports upgrade outcome.
ReportUpgradeResult(ctx context.Context, result UpgradeResult) error
// Events returns a channel that emits server-initiated events.
// In HTTP mode this channel is never written to (polling handles it).
// In WS mode, tasks/upgrade/control arrive here.

View file

@ -134,23 +134,13 @@ func TestE2EFullLifecycle(t *testing.T) {
t.Fatal("timeout waiting for cancel control")
}
// 6. Send upgrade result
err = tr.ReportUpgradeResult(ctx, UpgradeResult{
AgentID: "e2e-agent",
Success: true,
Version: "2.0.0",
})
if err != nil {
t.Fatalf("ReportUpgradeResult: %v", err)
}
// Verify server received all messages
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(receivedMessages) < 4 {
t.Fatalf("expected at least 4 messages, got %d", len(receivedMessages))
if len(receivedMessages) < 3 {
t.Fatalf("expected at least 3 messages, got %d", len(receivedMessages))
}
types := make([]string, len(receivedMessages))
@ -158,7 +148,7 @@ func TestE2EFullLifecycle(t *testing.T) {
types[i], _ = m["type"].(string)
}
expected := []string{"auth", "heartbeat", "progress", "upgrade-result"}
expected := []string{"auth", "heartbeat", "progress"}
for _, exp := range expected {
found := false
for _, got := range types {

View file

@ -34,6 +34,10 @@ func (t *HTTPTransport) SendProgress(ctx context.Context, update StatusUpdate) (
return t.client.ReportStatus(ctx, update)
}
func (t *HTTPTransport) BatchReportStatus(ctx context.Context, updates []StatusUpdate) (*BatchStatusResponse, error) {
return t.client.BatchReportStatus(ctx, updates)
}
func (t *HTTPTransport) ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error) {
return t.client.ClaimTasks(ctx, agentID)
}
@ -42,9 +46,5 @@ func (t *HTTPTransport) Deregister(ctx context.Context, agentID string) error {
return t.client.Deregister(ctx, agentID)
}
func (t *HTTPTransport) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
return t.client.ReportUpgradeResult(ctx, result)
}
// Client returns the underlying HTTP client for direct use if needed.
func (t *HTTPTransport) Client() *Client { return t.client }

View file

@ -120,18 +120,6 @@ func (h *HybridTransport) Deregister(ctx context.Context, agentID string) error
return h.http.Deregister(ctx, agentID)
}
// ReportUpgradeResult delegates to the active transport.
func (h *HybridTransport) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
if h.mode.Load() == "ws" {
if err := h.ws.ReportUpgradeResult(ctx, result); err != nil {
h.switchToHTTP()
return h.http.ReportUpgradeResult(ctx, result)
}
return nil
}
return h.http.ReportUpgradeResult(ctx, result)
}
// ── Internal ─────────────────────────────────────────────────────────────────
func (h *HybridTransport) switchToHTTP() {

View file

@ -209,22 +209,6 @@ func (t *WSTransport) Deregister(_ context.Context, _ string) error {
return t.Close()
}
// ReportUpgradeResult sends upgrade result to the DO.
func (t *WSTransport) ReportUpgradeResult(_ context.Context, result UpgradeResult) error {
msg := struct {
Type string `json:"type"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}{
Type: "upgrade-result",
Success: result.Success,
Version: result.Version,
Error: result.Error,
}
return t.send(msg)
}
// ── Internal ─────────────────────────────────────────────────────────────────
func (t *WSTransport) send(msg any) error {

View file

@ -111,10 +111,21 @@ type StatusResponse struct {
StreamRequested bool `json:"streamRequested,omitempty"`
}
// BatchStatusRequest wraps multiple status updates in a single request.
type BatchStatusRequest struct {
Updates []StatusUpdate `json:"updates"`
}
// BatchStatusResponse wraps per-task results from the batch endpoint.
type BatchStatusResponse struct {
Results []StatusResponse `json:"results"`
}
// HeartbeatResponse is returned by the server on heartbeat.
type HeartbeatResponse struct {
Success bool `json:"success"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Watching bool `json:"watching,omitempty"` // true when a user is viewing download progress in the web UI
}
// UpgradeSignal tells the agent to upgrade to a specific version.
@ -122,14 +133,6 @@ type UpgradeSignal struct {
Version string `json:"version"`
}
// UpgradeResult is sent by the agent after an upgrade attempt.
type UpgradeResult struct {
AgentID string `json:"agentId"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// ErrorResponse is returned on API errors.
type ErrorResponse struct {
Error string `json:"error"`

View file

@ -18,7 +18,6 @@ import (
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/usenet/download"
"github.com/torrentclaw/unarr/internal/upgrade"
)
// newStartCmd creates the top-level `unarr start` command.
@ -93,7 +92,6 @@ func newDaemonCmd() *cobra.Command {
return cmd
}
func runDaemonStart() error {
cfg := loadConfig()
bold := color.New(color.Bold)
@ -174,6 +172,7 @@ func runDaemonStart() error {
// Create progress reporter using transport
reporter := engine.NewProgressReporterWithTransport(transport, 3*time.Second)
reporter.SetWatchingFunc(func() bool { return d.Watching })
// Parse speed limits
maxDl, _ := config.ParseSpeed(cfg.Download.MaxDownloadSpeed)
@ -356,63 +355,6 @@ func runDaemonStart() error {
}
}
// Wire: server-requested upgrade
d.OnUpgradeRequested = func(targetVersion string) {
// Wait for active downloads to finish
if active := manager.ActiveCount(); active > 0 {
log.Printf("Waiting for %d active download(s) to finish before upgrading...", active)
manager.Wait()
}
upgrader := &upgrade.Upgrader{CurrentVersion: Version}
result := upgrader.Execute(ctx, targetVersion)
// Report result to server
reportCtx, reportCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer reportCancel()
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
upgradeResult := agent.UpgradeResult{
AgentID: cfg.Agent.ID,
Success: result.Success,
Version: result.NewVersion,
Error: errMsg,
}
_ = transport.ReportUpgradeResult(reportCtx, upgradeResult)
if !result.Success {
log.Printf("Upgrade failed: %v", result.Error)
d.ClearUpgradeInProgress()
return
}
// Restart: replace current process with the new binary
log.Printf("Upgrade successful (%s → %s), restarting...", result.OldVersion, result.NewVersion)
// Deregister first so the server knows we're restarting
deregCtx, deregCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer deregCancel()
_ = transport.Deregister(deregCtx, cfg.Agent.ID)
// Flush progress reporter
cancel()
// Re-exec with the same args — the new binary takes over
binPath, err := os.Executable()
if err != nil {
log.Printf("Could not determine executable path: %v", err)
os.Exit(75) // EX_TEMPFAIL
}
// syscall.Exec replaces the current process (Unix)
execErr := syscall.Exec(binPath, os.Args, os.Environ())
// If we get here, exec failed (e.g. Windows)
log.Printf("Exec failed: %v — exiting for service manager restart", execErr)
os.Exit(75)
}
// Config hot-reload (SIGUSR1 on Unix, no-op on Windows)
// Tickers are initialized inside d.Run(), so we pass the daemon
// and the reload goroutine reads them when the signal arrives.

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.3.0-dev"
var Version = "0.3.4-dev"

View file

@ -18,11 +18,22 @@ type StatusReporter interface {
ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error)
}
// BatchStatusReporter extends StatusReporter with batch support.
// Transports that implement this send all updates in a single request.
type BatchStatusReporter interface {
StatusReporter
BatchReportStatus(ctx context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error)
}
// WatchingFunc returns whether a user is actively viewing download progress.
type WatchingFunc func() bool
// ProgressReporter aggregates progress from downloads and reports to the API.
// It batches updates to avoid flooding the server.
type ProgressReporter struct {
reporter StatusReporter
interval time.Duration
isWatching WatchingFunc // nil = always report (backwards compatible)
onCancel ActionFunc
onPause ActionFunc
@ -73,6 +84,9 @@ func (r *ProgressReporter) SetDeleteFilesHandler(fn ActionFunc) { r.onDeleteFile
// SetStreamRequestedHandler sets the callback for stream activation.
func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStreamRequested = fn }
// SetWatchingFunc sets the function that checks if someone is viewing downloads.
func (r *ProgressReporter) SetWatchingFunc(fn WatchingFunc) { r.isWatching = fn }
// Track registers a task for progress tracking.
func (r *ProgressReporter) Track(task *Task) {
r.mu.Lock()
@ -111,22 +125,67 @@ func (r *ProgressReporter) flush(ctx context.Context) {
}
r.mu.Unlock()
// When nobody is watching, only report final states (completed/failed).
// This saves ~99% of API requests when the user isn't on the downloads page.
watching := r.isWatching == nil || r.isWatching()
var reportable []*Task
for _, task := range tasks {
status := task.GetStatus()
if status != StatusDownloading && status != StatusVerifying &&
status != StatusOrganizing && status != StatusSeeding &&
status != StatusCompleted && status != StatusFailed {
continue
isFinal := status == StatusCompleted || status == StatusFailed
isActive := status == StatusDownloading || status == StatusVerifying ||
status == StatusOrganizing || status == StatusSeeding
if isFinal || (watching && isActive) {
reportable = append(reportable, task)
}
}
if len(reportable) == 0 {
return
}
// Use batch when transport supports it
if batcher, ok := r.reporter.(BatchStatusReporter); ok {
r.flushBatch(ctx, batcher, reportable)
return
}
// Fallback: individual requests
for _, task := range reportable {
update := task.ToStatusUpdate()
resp, err := r.reporter.ReportStatus(ctx, update)
if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue
}
r.handleResponse(task, resp)
}
}
// Handle server-side signals
func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusReporter, tasks []*Task) {
updates := make([]agent.StatusUpdate, len(tasks))
for i, task := range tasks {
updates[i] = task.ToStatusUpdate()
}
resp, err := batcher.BatchReportStatus(ctx, updates)
if err != nil {
log.Printf("batch progress report failed: %v", err)
return
}
// Match results back to tasks by index (server returns in same order)
if len(resp.Results) != len(tasks) {
log.Printf("batch response mismatch: sent %d updates, got %d results", len(tasks), len(resp.Results))
}
for i, result := range resp.Results {
if i < len(tasks) {
r.handleResponse(tasks[i], &result)
}
}
}
func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse) {
if resp.Cancelled {
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
r.Untrack(task.ID)
@ -150,7 +209,6 @@ func (r *ProgressReporter) flush(ctx context.Context) {
}
}
}
}
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {

View file

@ -17,6 +17,7 @@ import (
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage"
"github.com/torrentclaw/unarr/internal/config"
"golang.org/x/term"
"golang.org/x/time/rate"
)
@ -96,7 +97,7 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
tcfg.Logger = alog.Default.FilterLevel(alog.Critical)
// --- Performance optimizations ---
@ -342,13 +343,20 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
}
lastBytesAt := time.Now()
lastBytes := int64(0)
isTTY := term.IsTerminal(int(os.Stderr.Fd()))
for {
select {
case <-ctx.Done():
if isTTY {
fmt.Fprintln(os.Stderr)
}
return nil, fmt.Errorf("cancelled")
case <-deadline:
if isTTY {
fmt.Fprintln(os.Stderr)
}
return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout)
case <-ticker.C:
@ -381,12 +389,17 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
// Peer stats
stats := t.Stats()
// Terminal progress (log.Printf for daemon-friendly output, no \r)
// Terminal progress
pct := int(float64(downloaded) / float64(totalBytes) * 100)
log.Printf("[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
line := fmt.Sprintf("[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
task.ID[:8], pct,
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed),
stats.ActivePeers, stats.ConnectedSeeders)
if isTTY {
fmt.Fprintf(os.Stderr, "\r\033[K%s", line)
} else {
log.Print(line)
}
// Report progress
p := Progress{
@ -407,6 +420,9 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
// Check completion
if downloaded >= totalBytes {
if isTTY {
fmt.Fprintln(os.Stderr) // newline after \r progress
}
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
return &Result{}, nil
}