refactor: migrate lint config to v2, remove daemon auto-upgrade, add trust badges
This commit is contained in:
parent
a13104bdb7
commit
efa4562acd
18 changed files with 188 additions and 268 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -81,7 +81,7 @@ jobs:
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
file: ./coverage.out
|
files: ./coverage.out
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- gosimple
|
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
|
@ -16,16 +17,13 @@ linters:
|
||||||
- errname
|
- errname
|
||||||
- errorlint
|
- errorlint
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- gofmt
|
|
||||||
- goimports
|
|
||||||
- misspell
|
- misspell
|
||||||
- nilerr
|
- nilerr
|
||||||
- prealloc
|
- prealloc
|
||||||
- unconvert
|
- unconvert
|
||||||
- unparam
|
- unparam
|
||||||
- wastedassign
|
- wastedassign
|
||||||
|
settings:
|
||||||
linters-settings:
|
|
||||||
gosec:
|
gosec:
|
||||||
excludes:
|
excludes:
|
||||||
- G104 # Allow unhandled errors in fire-and-forget (notifications)
|
- G104 # Allow unhandled errors in fire-and-forget (notifications)
|
||||||
|
|
@ -36,9 +34,14 @@ linters-settings:
|
||||||
default-signifies-exhaustive: true
|
default-signifies-exhaustive: true
|
||||||
misspell:
|
misspell:
|
||||||
locale: US
|
locale: US
|
||||||
|
exclusions:
|
||||||
issues:
|
paths:
|
||||||
exclude-dirs:
|
- dist
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
- dist
|
- dist
|
||||||
max-issues-per-linter: 50
|
|
||||||
max-same-issues: 5
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
> **⚠️ Beta** — unarr is under active development. Features may change, and bugs are expected. [Report issues here](https://github.com/torrentclaw/unarr/issues).
|
||||||
|
|
||||||
[](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml)
|
[](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/torrentclaw/unarr/releases)
|
||||||
[](https://goreportcard.com/report/github.com/torrentclaw/unarr)
|
[](https://goreportcard.com/report/github.com/torrentclaw/unarr)
|
||||||
|
[](https://codecov.io/gh/torrentclaw/unarr)
|
||||||
|
[](https://github.com/torrentclaw/unarr/releases)
|
||||||
|
[](https://hub.docker.com/r/torrentclaw/unarr)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
|
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -16,6 +16,7 @@ require (
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/torrentclaw/go-client v0.2.0
|
github.com/torrentclaw/go-client v0.2.0
|
||||||
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
|
||||||
|
|
@ -73,17 +73,6 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
|
||||||
return nil
|
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.
|
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
|
||||||
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
||||||
var resp StatusResponse
|
var resp StatusResponse
|
||||||
|
|
@ -93,6 +82,15 @@ func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*Status
|
||||||
return &resp, nil
|
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
|
// Usenet endpoints
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -324,62 +324,3 @@ func TestHeartbeatWithoutUpgradeSignal(t *testing.T) {
|
||||||
t.Errorf("expected no upgrade signal, got %+v", resp.Upgrade)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ type Daemon struct {
|
||||||
// Callbacks
|
// Callbacks
|
||||||
OnTasksClaimed func(tasks []Task)
|
OnTasksClaimed func(tasks []Task)
|
||||||
OnStreamRequested func(req StreamRequest)
|
OnStreamRequested func(req StreamRequest)
|
||||||
OnUpgradeRequested func(version string)
|
|
||||||
OnControlAction func(action, taskID string)
|
OnControlAction func(action, taskID string)
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
@ -35,13 +34,17 @@ type Daemon struct {
|
||||||
Features FeatureFlags
|
Features FeatureFlags
|
||||||
Info AgentInfo
|
Info AgentInfo
|
||||||
State DaemonState
|
State DaemonState
|
||||||
upgradeInProgress bool
|
|
||||||
heartbeatFailures int
|
heartbeatFailures int
|
||||||
|
lastNotifiedVersion string
|
||||||
|
|
||||||
// Callbacks for state tracking (set by cmd/daemon.go)
|
// Callbacks for state tracking (set by cmd/daemon.go)
|
||||||
GetActiveCount func() int
|
GetActiveCount func() int
|
||||||
GetCleanableBytes func() int64
|
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
|
// Exposed tickers for hot-reload
|
||||||
PollTicker *time.Ticker
|
PollTicker *time.Ticker
|
||||||
HeartbeatTicker *time.Ticker
|
HeartbeatTicker *time.Ticker
|
||||||
|
|
@ -191,20 +194,18 @@ func (d *Daemon) heartbeat(ctx context.Context) {
|
||||||
d.heartbeatFailures = 0
|
d.heartbeatFailures = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state file
|
// Update watching flag and state file
|
||||||
|
d.Watching = resp.Watching
|
||||||
d.State.LastHeartbeat = time.Now()
|
d.State.LastHeartbeat = time.Now()
|
||||||
if d.GetActiveCount != nil {
|
if d.GetActiveCount != nil {
|
||||||
d.State.ActiveTasks = d.GetActiveCount()
|
d.State.ActiveTasks = d.GetActiveCount()
|
||||||
}
|
}
|
||||||
WriteState(&d.State)
|
WriteState(&d.State)
|
||||||
|
|
||||||
// Check for upgrade signal from server
|
// Log once per version when server suggests an upgrade
|
||||||
if resp.Upgrade != nil && resp.Upgrade.Version != "" && !d.upgradeInProgress {
|
if resp.Upgrade != nil && resp.Upgrade.Version != "" && resp.Upgrade.Version != d.lastNotifiedVersion {
|
||||||
d.upgradeInProgress = true
|
d.lastNotifiedVersion = resp.Upgrade.Version
|
||||||
log.Printf("Upgrade requested by server: %s → %s", d.cfg.Version, resp.Upgrade.Version)
|
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", resp.Upgrade.Version)
|
||||||
if d.OnUpgradeRequested != nil {
|
|
||||||
go d.OnUpgradeRequested(resp.Upgrade.Version)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,12 +226,9 @@ func (d *Daemon) handleEvent(event ServerEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
if event.Upgrade != nil && event.Upgrade.Version != "" && !d.upgradeInProgress {
|
if event.Upgrade != nil && event.Upgrade.Version != "" && event.Upgrade.Version != d.lastNotifiedVersion {
|
||||||
d.upgradeInProgress = true
|
d.lastNotifiedVersion = event.Upgrade.Version
|
||||||
log.Printf("Upgrade requested via WebSocket: %s → %s", d.cfg.Version, event.Upgrade.Version)
|
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", event.Upgrade.Version)
|
||||||
if d.OnUpgradeRequested != nil {
|
|
||||||
go d.OnUpgradeRequested(event.Upgrade.Version)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "control":
|
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() {
|
func (d *Daemon) deregister() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ type Transport interface {
|
||||||
// Deregister notifies the server of graceful shutdown.
|
// Deregister notifies the server of graceful shutdown.
|
||||||
Deregister(ctx context.Context, agentID string) error
|
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.
|
// Events returns a channel that emits server-initiated events.
|
||||||
// In HTTP mode this channel is never written to (polling handles it).
|
// In HTTP mode this channel is never written to (polling handles it).
|
||||||
// In WS mode, tasks/upgrade/control arrive here.
|
// In WS mode, tasks/upgrade/control arrive here.
|
||||||
|
|
|
||||||
|
|
@ -134,23 +134,13 @@ func TestE2EFullLifecycle(t *testing.T) {
|
||||||
t.Fatal("timeout waiting for cancel control")
|
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
|
// Verify server received all messages
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if len(receivedMessages) < 4 {
|
if len(receivedMessages) < 3 {
|
||||||
t.Fatalf("expected at least 4 messages, got %d", len(receivedMessages))
|
t.Fatalf("expected at least 3 messages, got %d", len(receivedMessages))
|
||||||
}
|
}
|
||||||
|
|
||||||
types := make([]string, len(receivedMessages))
|
types := make([]string, len(receivedMessages))
|
||||||
|
|
@ -158,7 +148,7 @@ func TestE2EFullLifecycle(t *testing.T) {
|
||||||
types[i], _ = m["type"].(string)
|
types[i], _ = m["type"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"auth", "heartbeat", "progress", "upgrade-result"}
|
expected := []string{"auth", "heartbeat", "progress"}
|
||||||
for _, exp := range expected {
|
for _, exp := range expected {
|
||||||
found := false
|
found := false
|
||||||
for _, got := range types {
|
for _, got := range types {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ func (t *HTTPTransport) SendProgress(ctx context.Context, update StatusUpdate) (
|
||||||
return t.client.ReportStatus(ctx, update)
|
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) {
|
func (t *HTTPTransport) ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error) {
|
||||||
return t.client.ClaimTasks(ctx, agentID)
|
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)
|
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.
|
// Client returns the underlying HTTP client for direct use if needed.
|
||||||
func (t *HTTPTransport) Client() *Client { return t.client }
|
func (t *HTTPTransport) Client() *Client { return t.client }
|
||||||
|
|
|
||||||
|
|
@ -120,18 +120,6 @@ func (h *HybridTransport) Deregister(ctx context.Context, agentID string) error
|
||||||
return h.http.Deregister(ctx, agentID)
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *HybridTransport) switchToHTTP() {
|
func (h *HybridTransport) switchToHTTP() {
|
||||||
|
|
|
||||||
|
|
@ -209,22 +209,6 @@ func (t *WSTransport) Deregister(_ context.Context, _ string) error {
|
||||||
return t.Close()
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (t *WSTransport) send(msg any) error {
|
func (t *WSTransport) send(msg any) error {
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,21 @@ type StatusResponse struct {
|
||||||
StreamRequested bool `json:"streamRequested,omitempty"`
|
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.
|
// HeartbeatResponse is returned by the server on heartbeat.
|
||||||
type HeartbeatResponse struct {
|
type HeartbeatResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
|
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.
|
// UpgradeSignal tells the agent to upgrade to a specific version.
|
||||||
|
|
@ -122,14 +133,6 @@ type UpgradeSignal struct {
|
||||||
Version string `json:"version"`
|
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.
|
// ErrorResponse is returned on API errors.
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/engine"
|
"github.com/torrentclaw/unarr/internal/engine"
|
||||||
"github.com/torrentclaw/unarr/internal/library"
|
"github.com/torrentclaw/unarr/internal/library"
|
||||||
"github.com/torrentclaw/unarr/internal/usenet/download"
|
"github.com/torrentclaw/unarr/internal/usenet/download"
|
||||||
"github.com/torrentclaw/unarr/internal/upgrade"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newStartCmd creates the top-level `unarr start` command.
|
// newStartCmd creates the top-level `unarr start` command.
|
||||||
|
|
@ -93,7 +92,6 @@ func newDaemonCmd() *cobra.Command {
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func runDaemonStart() error {
|
func runDaemonStart() error {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
bold := color.New(color.Bold)
|
bold := color.New(color.Bold)
|
||||||
|
|
@ -174,6 +172,7 @@ func runDaemonStart() error {
|
||||||
|
|
||||||
// Create progress reporter using transport
|
// Create progress reporter using transport
|
||||||
reporter := engine.NewProgressReporterWithTransport(transport, 3*time.Second)
|
reporter := engine.NewProgressReporterWithTransport(transport, 3*time.Second)
|
||||||
|
reporter.SetWatchingFunc(func() bool { return d.Watching })
|
||||||
|
|
||||||
// Parse speed limits
|
// Parse speed limits
|
||||||
maxDl, _ := config.ParseSpeed(cfg.Download.MaxDownloadSpeed)
|
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)
|
// Config hot-reload (SIGUSR1 on Unix, no-op on Windows)
|
||||||
// Tickers are initialized inside d.Run(), so we pass the daemon
|
// Tickers are initialized inside d.Run(), so we pass the daemon
|
||||||
// and the reload goroutine reads them when the signal arrives.
|
// and the reload goroutine reads them when the signal arrives.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||||
var Version = "0.3.0-dev"
|
var Version = "0.3.4-dev"
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,22 @@ type StatusReporter interface {
|
||||||
ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error)
|
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.
|
// ProgressReporter aggregates progress from downloads and reports to the API.
|
||||||
// It batches updates to avoid flooding the server.
|
// It batches updates to avoid flooding the server.
|
||||||
type ProgressReporter struct {
|
type ProgressReporter struct {
|
||||||
reporter StatusReporter
|
reporter StatusReporter
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
isWatching WatchingFunc // nil = always report (backwards compatible)
|
||||||
|
|
||||||
onCancel ActionFunc
|
onCancel ActionFunc
|
||||||
onPause ActionFunc
|
onPause ActionFunc
|
||||||
|
|
@ -73,6 +84,9 @@ func (r *ProgressReporter) SetDeleteFilesHandler(fn ActionFunc) { r.onDeleteFile
|
||||||
// SetStreamRequestedHandler sets the callback for stream activation.
|
// SetStreamRequestedHandler sets the callback for stream activation.
|
||||||
func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStreamRequested = fn }
|
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.
|
// Track registers a task for progress tracking.
|
||||||
func (r *ProgressReporter) Track(task *Task) {
|
func (r *ProgressReporter) Track(task *Task) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
@ -111,22 +125,67 @@ func (r *ProgressReporter) flush(ctx context.Context) {
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
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 {
|
for _, task := range tasks {
|
||||||
status := task.GetStatus()
|
status := task.GetStatus()
|
||||||
if status != StatusDownloading && status != StatusVerifying &&
|
isFinal := status == StatusCompleted || status == StatusFailed
|
||||||
status != StatusOrganizing && status != StatusSeeding &&
|
isActive := status == StatusDownloading || status == StatusVerifying ||
|
||||||
status != StatusCompleted && status != StatusFailed {
|
status == StatusOrganizing || status == StatusSeeding
|
||||||
continue
|
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()
|
update := task.ToStatusUpdate()
|
||||||
resp, err := r.reporter.ReportStatus(ctx, update)
|
resp, err := r.reporter.ReportStatus(ctx, update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
|
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
|
||||||
continue
|
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 {
|
if resp.Cancelled {
|
||||||
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
|
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
|
||||||
r.Untrack(task.ID)
|
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.
|
// ReportFinal sends a final status update for a completed/failed task.
|
||||||
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
|
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/anacrolix/torrent"
|
"github.com/anacrolix/torrent"
|
||||||
"github.com/anacrolix/torrent/storage"
|
"github.com/anacrolix/torrent/storage"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
"golang.org/x/term"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,7 +97,7 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
||||||
tcfg.DataDir = cfg.DataDir
|
tcfg.DataDir = cfg.DataDir
|
||||||
tcfg.Seed = cfg.SeedEnabled
|
tcfg.Seed = cfg.SeedEnabled
|
||||||
tcfg.NoUpload = !cfg.SeedEnabled
|
tcfg.NoUpload = !cfg.SeedEnabled
|
||||||
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
|
tcfg.Logger = alog.Default.FilterLevel(alog.Critical)
|
||||||
|
|
||||||
// --- Performance optimizations ---
|
// --- Performance optimizations ---
|
||||||
|
|
||||||
|
|
@ -342,13 +343,20 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
|
||||||
}
|
}
|
||||||
lastBytesAt := time.Now()
|
lastBytesAt := time.Now()
|
||||||
lastBytes := int64(0)
|
lastBytes := int64(0)
|
||||||
|
isTTY := term.IsTerminal(int(os.Stderr.Fd()))
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
if isTTY {
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("cancelled")
|
return nil, fmt.Errorf("cancelled")
|
||||||
|
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
|
if isTTY {
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout)
|
return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout)
|
||||||
|
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
|
@ -381,12 +389,17 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
|
||||||
// Peer stats
|
// Peer stats
|
||||||
stats := t.Stats()
|
stats := t.Stats()
|
||||||
|
|
||||||
// Terminal progress (log.Printf for daemon-friendly output, no \r)
|
// Terminal progress
|
||||||
pct := int(float64(downloaded) / float64(totalBytes) * 100)
|
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,
|
task.ID[:8], pct,
|
||||||
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed),
|
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed),
|
||||||
stats.ActivePeers, stats.ConnectedSeeders)
|
stats.ActivePeers, stats.ConnectedSeeders)
|
||||||
|
if isTTY {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K%s", line)
|
||||||
|
} else {
|
||||||
|
log.Print(line)
|
||||||
|
}
|
||||||
|
|
||||||
// Report progress
|
// Report progress
|
||||||
p := Progress{
|
p := Progress{
|
||||||
|
|
@ -407,6 +420,9 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
|
||||||
|
|
||||||
// Check completion
|
// Check completion
|
||||||
if downloaded >= totalBytes {
|
if downloaded >= totalBytes {
|
||||||
|
if isTTY {
|
||||||
|
fmt.Fprintln(os.Stderr) // newline after \r progress
|
||||||
|
}
|
||||||
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
|
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
|
||||||
return &Result{}, nil
|
return &Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue