unarr/internal/cmd/root.go
Deivid Soto 0537de0ec1 fix(upgrade): fetch releases from TorrentClaw app, not GitHub
The org GitHub shadow-ban 404s releases/raw/API to anonymous clients, so the
self-updater (api.github.com/releases/latest + github.com/.../releases/download)
was broken: `unarr upgrade` could neither check nor download.

- fetchLatestVersion → GET {base}/version (plain text)
- releaseURL → {base}/releases/download/v{ver}/{file}
- base resolves from cfg.Auth.APIURL via upgrade.SetBaseURL (PersistentPreRun),
  so mirrors / onion / staging / UNARR_API_URL all route updates correctly
- tests updated to the new endpoints
2026-05-21 14:46:10 +02:00

230 lines
5.7 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"
"github.com/torrentclaw/unarr/internal/upgrade"
)
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
}
// Self-updater fetches releases from the configured host (default
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
// UNARR_API_URL all route updates correctly.
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
},
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"
loginCmd := newLoginCmd()
loginCmd.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"
probeHWAccelCmd := newProbeHWAccelCmd()
probeHWAccelCmd.GroupID = "system"
cleanCmd := newCleanCmd()
cleanCmd.GroupID = "system"
mirrorsCmd := newMirrorsCmd()
mirrorsCmd.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,
loginCmd,
configCmd,
migrateCmd,
// Search & Discovery
searchCmd,
inspectCmd,
popularCmd,
recentCmd,
watchCmd,
// Downloads & Streaming
downloadCmd,
streamCmd,
// Daemon Management
startCmd,
stopCmd,
statusCmd,
daemonCmd,
// System & Diagnostics
statsCmd,
doctorCmd,
probeHWAccelCmd,
cleanCmd,
mirrorsCmd,
selfUpdateCmd,
versionCmd,
completionCmd,
// Library
scanCmd,
// Alias: upgrade → self-update
newUpgradeCmd(),
)
}
// 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
}