feat(cli): upgrade command, rich status, and version cache
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

- Replace `upgrade` stub with real command (alias for `self-update`)
- Also register `update` as alias: `unarr update` works too
- Rewrite `status` to show full config, disk usage, daemon state, and
  update availability with colored sections
- Add version check cache (1h TTL) so `status` is instant on repeat runs
- Guard against division by zero on empty filesystems
- Guard against negative durations from clock skew
- Guard against stale PID via heartbeat recency check (2 min)
- Add comprehensive test coverage across agent, engine, upgrade, usenet,
  arr, library, mediaserver, and UI packages
- Improve Makefile coverage target to exclude cmd/ glue code
- Fix stream handler resource cleanup and ffprobe error handling
This commit is contained in:
Deivid Soto 2026-03-31 22:05:43 +02:00
parent 01d62ffa13
commit 3e0f3a5a64
33 changed files with 7084 additions and 65 deletions

View file

@ -0,0 +1,55 @@
package cmd
import "testing"
func TestValidateSpeed(t *testing.T) {
tests := []struct {
input string
wantErr bool
}{
{"", false},
{"0", false},
{" ", false},
{"10MB", false},
{"500KB", false},
{"1GB", false},
{"abc", true},
{"10XB", true},
{"-5MB", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := validateSpeed(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateSpeed(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestValidateDuration(t *testing.T) {
tests := []struct {
input string
wantErr bool
}{
{"", false},
{"30s", false},
{"1m", false},
{"5m", false},
{"1h", false},
{"2h30m", false},
{"abc", true},
{"30", true},
{"5x", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := validateDuration(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

View file

@ -0,0 +1,55 @@
package cmd
import "testing"
func TestDeriveWSURL(t *testing.T) {
tests := []struct {
apiURL string
agentID string
want string
}{
{"https://torrentclaw.com", "agent-123", "wss://unarr.torrentclaw.com/ws/agent-123"},
{"http://localhost:3000", "a1", ""}, // localhost skipped
{"http://127.0.0.1:3000", "a1", ""}, // 127.0.0.1 skipped
{"https://torrentclaw.com/", "a1", "wss://unarr.torrentclaw.com/ws/a1"},
{"https://api.example.io", "x", "wss://unarr.api.example.io/ws/x"},
{"", "agent-123", ""},
{"https://torrentclaw.com", "", ""},
{"", "", ""},
}
for _, tt := range tests {
t.Run(tt.apiURL+"_"+tt.agentID, func(t *testing.T) {
got := deriveWSURL(tt.apiURL, tt.agentID)
if got != tt.want {
t.Errorf("deriveWSURL(%q, %q) = %q, want %q", tt.apiURL, tt.agentID, got, tt.want)
}
})
}
}
func TestFormatSpeedLog(t *testing.T) {
tests := []struct {
bps int64
want string
}{
{0, "0 B/s"},
{500, "500 B/s"},
{1023, "1023 B/s"},
{1024, "1 KB/s"},
{10240, "10 KB/s"},
{1048576, "1.0 MB/s"},
{5242880, "5.0 MB/s"},
{1073741824, "1.0 GB/s"},
{2147483648, "2.0 GB/s"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := formatSpeedLog(tt.bps)
if got != tt.want {
t.Errorf("formatSpeedLog(%d) = %q, want %q", tt.bps, got, tt.want)
}
})
}
}

View file

@ -0,0 +1,43 @@
package cmd
import (
"os"
"strings"
"testing"
)
func TestExpandHome(t *testing.T) {
home, _ := os.UserHomeDir()
tests := []struct {
input string
want string
}{
{"~/Documents", home + "/Documents"},
{"~/", home},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
{"", ""},
{"~notexpanded", "~notexpanded"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := expandHome(tt.input)
if got != tt.want {
t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestDefaultDownloadDir(t *testing.T) {
dir := defaultDownloadDir()
if dir == "" {
t.Error("defaultDownloadDir() returned empty string")
}
home, _ := os.UserHomeDir()
if !strings.HasPrefix(dir, home) {
t.Errorf("defaultDownloadDir() = %q, expected to start with home dir %q", dir, home)
}
}

View file

@ -143,8 +143,9 @@ Source: https://github.com/torrentclaw/unarr`,
completionCmd,
// Library
scanCmd,
// Alias: upgrade → self-update
newUpgradeCmd(),
// Stubs for future commands
newStubCmd("upgrade", "Find a better version of a torrent"),
newStubCmd("moreseed", "Find same quality with more seeders"),
newStubCmd("compare", "Compare two torrents side by side"),
newStubCmd("add", "Search and add torrents to your client"),

View file

@ -1,20 +1,26 @@
package cmd
import (
"context"
"fmt"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade"
)
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show daemon status and active downloads",
Long: `Display the current state of the daemon, active downloads, and recent activity.
Short: "Show daemon status, configuration, and update availability",
Long: `Display the current state of unarr: version, configuration, daemon status,
disk usage, and whether an update is available.
Shows the configured agent name, download directory, and preferred method.
When the daemon is running, also displays active downloads and their progress.`,
When the daemon is running, also displays uptime, active downloads, and stats.`,
Example: ` unarr status`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus()
@ -25,27 +31,167 @@ When the daemon is running, also displays active downloads and their progress.`,
func runStatus() error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
fmt.Println()
bold.Printf(" unarr %s\n", Version)
dim.Printf(" %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Println()
cfg := loadConfig()
// ── Configuration ──
if cfg.Auth.APIKey == "" {
dim.Println(" Not configured. Run 'unarr init' first.")
yellow.Println(" ⚠ Not configured. Run 'unarr init' first.")
fmt.Println()
return nil
}
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...")
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
cyan.Println(" Configuration")
agentID := cfg.Agent.ID
if len(agentID) > 8 {
agentID = agentID[:8] + "..."
}
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, agentID)
fmt.Printf(" Server: %s\n", cfg.Auth.APIURL)
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
if cfg.Download.PreferredQuality != "" {
fmt.Printf(" Quality: %s\n", cfg.Download.PreferredQuality)
}
fmt.Printf(" Concurrent: %d\n", cfg.Download.MaxConcurrent)
if cfg.Organize.Enabled {
fmt.Printf(" Organize: on")
if cfg.Organize.MoviesDir != "" {
fmt.Printf(" (movies: %s", cfg.Organize.MoviesDir)
if cfg.Organize.TVShowsDir != "" {
fmt.Printf(", tv: %s", cfg.Organize.TVShowsDir)
}
fmt.Print(")")
}
fmt.Println()
}
fmt.Println()
dim.Println(" Daemon not running. Start with 'unarr start'")
dim.Println(" (Live status will be shown here when daemon is running)")
// ── Disk ──
if cfg.Download.Dir != "" {
if free, total, err := agent.DiskInfo(cfg.Download.Dir); err == nil && total > 0 {
usedPct := float64(total-free) / float64(total) * 100
cyan.Println(" Disk")
fmt.Printf(" Free: %s / %s (%.0f%% used)\n", formatBytes(free), formatBytes(total), usedPct)
if usedPct > 90 {
yellow.Println(" ⚠ Low disk space!")
}
fmt.Println()
}
}
// ── Daemon ──
cyan.Println(" Daemon")
state := agent.ReadState()
if state != nil && isDaemonAlive(state) {
green.Printf(" Status: running (PID %d)\n", state.PID)
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
fmt.Printf(" Last beat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
fmt.Printf(" Completed: %d\n", state.CompletedCount)
if state.FailedCount > 0 {
fmt.Printf(" Failed: %d\n", state.FailedCount)
}
if state.TotalDownloaded > 0 {
fmt.Printf(" Downloaded: %s\n", formatBytes(state.TotalDownloaded))
}
if len(state.MethodStats) > 0 {
parts := make([]string, 0, len(state.MethodStats))
for method, count := range state.MethodStats {
parts = append(parts, fmt.Sprintf("%s:%d", method, count))
}
fmt.Printf(" Methods: %s\n", strings.Join(parts, ", "))
}
} else {
dim.Println(" Status: stopped")
dim.Println(" Start with: unarr start")
}
fmt.Println()
// ── Update check (cached: instant if <1h, otherwise async 3s) ──
type versionResult struct {
version string
fromCache bool
err error
}
versionCh := make(chan versionResult, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
v, cached, err := upgrade.CheckLatestCached(ctx)
versionCh <- versionResult{v, cached, err}
}()
cyan.Println(" Update")
fmt.Print(" Checking... ")
vr := <-versionCh
if vr.err != nil {
dim.Println("could not check (offline?)")
} else {
currentClean := strings.TrimPrefix(Version, "v")
if currentClean == vr.version {
green.Printf("✓ up to date (v%s)\n", vr.version)
} else {
yellow.Printf("v%s available! ", vr.version)
fmt.Printf("Run: unarr upgrade\n")
}
}
fmt.Println()
return nil
}
// isDaemonAlive checks if the daemon process from the state file is still running.
// Guards against PID reuse by also checking heartbeat recency.
func isDaemonAlive(state *agent.DaemonState) bool {
if state.PID == 0 {
return false
}
// Reject stale state: if last heartbeat is older than 2 minutes, the daemon
// likely crashed and the PID may have been reused by another process.
if !state.LastHeartbeat.IsZero() && time.Since(state.LastHeartbeat) > 2*time.Minute {
return false
}
return agent.IsProcessAlive(state.PID)
}
// formatBytes formats bytes into human-readable string.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatDuration formats a duration into a compact human-readable string.
func formatDuration(d time.Duration) string {
if d < 0 {
return "0s"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%dd %dh", days, hours)
}

View file

@ -24,6 +24,20 @@ var streamRegistry = struct {
servers: make(map[string]*engine.StreamServer),
}
// cancelAllStreams cancels all active stream tasks and servers (only 1 stream at a time).
func cancelAllStreams() {
streamRegistry.mu.Lock()
for taskID, cancel := range streamRegistry.cancels {
cancel()
delete(streamRegistry.cancels, taskID)
}
for taskID, srv := range streamRegistry.servers {
srv.Shutdown(context.Background())
delete(streamRegistry.servers, taskID)
}
streamRegistry.mu.Unlock()
}
// cancelStreamTask cancels a running stream task and shuts down any stream server.
func cancelStreamTask(taskID string) {
streamRegistry.mu.Lock()
@ -94,7 +108,7 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
}
// 4. Start HTTP server
srv := engine.NewStreamServer(eng, 0)
srv := engine.NewStreamServer(eng, cfg.Download.StreamPort)
streamURL, err := srv.Start(ctx)
if err != nil {
task.ErrorMessage = "start HTTP server: " + err.Error()
@ -107,17 +121,27 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
task.StreamURL = streamURL
log.Printf("[%s] stream ready: %s", at.ID[:8], streamURL)
// 6. Progress loop
// 6. Unified progress + idle timeout loop
eng.StartProgressLoop(ctx)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
progressTicker := time.NewTicker(3 * time.Second)
defer progressTicker.Stop()
idleCheck := time.NewTicker(60 * time.Second)
defer idleCheck.Stop()
completed := false
for {
select {
case <-ctx.Done():
log.Printf("[%s] stream stopped", at.ID[:8])
return
case <-ticker.C:
case <-idleCheck.C:
if srv.IdleSince() > 30*time.Minute {
log.Printf("[%s] stream idle timeout (30m no HTTP requests), shutting down", at.ID[:8])
return
}
case <-progressTicker.C:
p := eng.Progress()
task.UpdateProgress(engine.Progress{
DownloadedBytes: p.DownloadedBytes,
@ -129,7 +153,7 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
})
// Terminal progress
if p.TotalBytes > 0 {
if !completed && p.TotalBytes > 0 {
pct := int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100)
fmt.Fprintf(os.Stderr, "\r[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
at.ID[:8], pct,
@ -137,20 +161,11 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
p.Peers, p.Seeds)
}
if p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
fmt.Fprint(os.Stderr, "\r\033[2K") // clear progress line
if !completed && p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
fmt.Fprint(os.Stderr, "\r\033[2K")
task.Transition(engine.StatusCompleted)
log.Printf("[%s] stream download complete, server stays up for 30m or until cancelled", at.ID[:8])
// Keep HTTP server running so the player can finish reading.
// Auto-shutdown after 30 minutes of idle to prevent resource leaks.
idleTimer := time.NewTimer(30 * time.Minute)
defer idleTimer.Stop()
select {
case <-ctx.Done():
case <-idleTimer.C:
log.Printf("[%s] stream idle timeout (30m), shutting down", at.ID[:8])
}
return
log.Printf("[%s] stream download complete, server stays up until idle (30m)", at.ID[:8])
completed = true
}
}
}

30
internal/cmd/upgrade.go Normal file
View file

@ -0,0 +1,30 @@
package cmd
import (
"github.com/spf13/cobra"
)
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
func newUpgradeCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "upgrade",
Aliases: []string{"update"},
Short: "Update unarr to the latest version",
Long: `Download and install the latest version of unarr.
This is an alias for 'unarr self-update'. Checks GitHub for the latest
release, verifies the checksum, and replaces the current binary.
A backup is kept at <binary>.backup.`,
Example: ` unarr upgrade
unarr upgrade --force`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
return cmd
}