feat(cli): upgrade command, rich status, and version cache
- 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:
parent
01d62ffa13
commit
3e0f3a5a64
33 changed files with 7084 additions and 65 deletions
55
internal/cmd/config_menu_test.go
Normal file
55
internal/cmd/config_menu_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
internal/cmd/daemon_test.go
Normal file
55
internal/cmd/daemon_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
internal/cmd/helpers_test.go
Normal file
43
internal/cmd/helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
30
internal/cmd/upgrade.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue