252 lines
7.1 KiB
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)
|
|
}
|