unarr/internal/cmd/root.go
Deivid Soto 3e0f3a5a64
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
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
2026-03-31 22:05:43 +02:00

222 lines
5.6 KiB
Go

package cmd
import (
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry"
)
var (
cfgFile string
apiKeyFlag string
jsonOut bool
noColor bool
rootCmd *cobra.Command
apiClient *tc.Client
appCfg config.Config
cfgLoaded bool
)
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection — all from your terminal.
Get started:
unarr init First-time configuration wizard
unarr search "breaking bad" Search for content
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
Source: https://github.com/torrentclaw/unarr`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
},
SilenceUsage: true,
SilenceErrors: true,
}
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.config/unarr/config.toml)")
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "API key (overrides config file and env)")
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON (for piping)")
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
// Getting Started
initCmd := newInitCmd()
initCmd.GroupID = "start"
configCmd := newConfigCmd()
configCmd.GroupID = "start"
migrateCmd := newMigrateCmd()
migrateCmd.GroupID = "start"
// Search & Discovery
searchCmd := newSearchCmd()
searchCmd.GroupID = "search"
inspectCmd := newInspectCmd()
inspectCmd.GroupID = "search"
popularCmd := newPopularCmd()
popularCmd.GroupID = "search"
recentCmd := newRecentCmd()
recentCmd.GroupID = "search"
watchCmd := newWatchCmd()
watchCmd.GroupID = "search"
// Downloads & Streaming
downloadCmd := newDownloadCmd()
downloadCmd.GroupID = "download"
streamCmd := newStreamCmd()
streamCmd.GroupID = "download"
// Daemon Management
startCmd := newStartCmd()
startCmd.GroupID = "daemon"
stopCmd := newStopCmd()
stopCmd.GroupID = "daemon"
statusCmd := newStatusCmd()
statusCmd.GroupID = "daemon"
daemonCmd := newDaemonCmd()
daemonCmd.GroupID = "daemon"
// System & Diagnostics
statsCmd := newStatsCmd()
statsCmd.GroupID = "system"
doctorCmd := newDoctorCmd()
doctorCmd.GroupID = "system"
cleanCmd := newCleanCmd()
cleanCmd.GroupID = "system"
selfUpdateCmd := newSelfUpdateCmd()
selfUpdateCmd.GroupID = "system"
versionCmd := newVersionCmd()
versionCmd.GroupID = "system"
completionCmd := newCompletionCmd()
completionCmd.GroupID = "system"
// Library
scanCmd := newScanCmd()
scanCmd.GroupID = "search"
rootCmd.AddCommand(
// Getting Started
initCmd,
configCmd,
migrateCmd,
// Search & Discovery
searchCmd,
inspectCmd,
popularCmd,
recentCmd,
watchCmd,
// Downloads & Streaming
downloadCmd,
streamCmd,
// Daemon Management
startCmd,
stopCmd,
statusCmd,
daemonCmd,
// System & Diagnostics
statsCmd,
doctorCmd,
cleanCmd,
selfUpdateCmd,
versionCmd,
completionCmd,
// Library
scanCmd,
// Alias: upgrade → self-update
newUpgradeCmd(),
// Stubs for future commands
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"),
newStubCmd("monitor", "Watch for new episodes of a series"),
newStubCmd("open", "Open content in the browser"),
)
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
// Report to Sentry with command context
command := ""
if cmd, _, cerr := rootCmd.Find(os.Args[1:]); cerr == nil && cmd != nil && cmd != rootCmd {
command = cmd.Name()
}
sentry.CaptureError(err, command)
sentry.Close() // Flush before os.Exit (defers don't run after os.Exit)
fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err))
os.Exit(1)
}
}
// loadConfig loads config once (lazy initialization).
func loadConfig() config.Config {
if cfgLoaded {
return appCfg
}
var err error
appCfg, err = config.Load(cfgFile)
if err != nil {
fmt.Fprintln(os.Stderr, color.YellowString("Warning: config load failed: %s", err))
appCfg = config.Default()
}
appCfg.ApplyEnvOverrides()
cfgLoaded = true
if appCfg.Agent.ID != "" {
sentry.SetUser(appCfg.Agent.ID)
}
return appCfg
}
// getClient returns a configured API client, initializing it on first use.
func getClient() *tc.Client {
if apiClient != nil {
return apiClient
}
cfg := loadConfig()
var opts []tc.Option
if cfg.Auth.APIURL != "" {
opts = append(opts, tc.WithBaseURL(cfg.Auth.APIURL))
}
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
}
if apiKey != "" {
opts = append(opts, tc.WithAPIKey(apiKey))
}
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
apiClient = tc.NewClient(opts...)
return apiClient
}