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
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.out
|
||||
files: ./coverage.out
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
[](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml)
|
||||
[](https://github.com/torrentclaw/unarr/releases)
|
||||
[](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)
|
||||
[](go.mod)
|
||||
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -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
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/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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue