unarr/internal/cmd/status.go

252 lines
7.1 KiB
Go

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, configuration, and update availability",
Long: `Display the current state of unarr: version, configuration, daemon status,
disk usage, and whether an update is available.
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()
},
}
}
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 == "" {
yellow.Println(" ⚠ Not configured. Run 'unarr init' first.")
fmt.Println()
return nil
}
// ── Account (async fetch) ──
type accountResult struct {
user agent.UserInfo
err error
}
accountCh := make(chan accountResult, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name,
Version: Version,
})
if err != nil {
accountCh <- accountResult{err: err}
return
}
accountCh <- accountResult{user: resp.User}
}()
cyan.Println(" Account")
ar := <-accountCh
if ar.err != nil {
dim.Println(" Could not fetch account info")
} else {
fmt.Printf(" User: %s\n", ar.user.Name)
fmt.Printf(" Email: %s\n", ar.user.Email)
planColor := dim
if ar.user.IsPro {
planColor = green
}
planColor.Printf(" Plan: %s\n", strings.ToUpper(ar.user.Plan))
}
fmt.Println()
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()
// ── 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 dirSize, err := agent.DirSize(cfg.Download.Dir); err == nil {
fmt.Printf(" Downloads: %s\n", formatBytes(dirSize))
}
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)
}
// formatFeatures returns a comma-separated list of available features, or "".
func formatFeatures(f agent.FeatureFlags) string {
var features []string
if f.Torrent {
features = append(features, "Torrent")
}
if f.Debrid {
features = append(features, "Debrid")
}
if f.Usenet {
features = append(features, "Usenet")
}
return strings.Join(features, ", ")
}
// 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)
}