From 4d35e197f0632ba45071a7f6bd045e32d8c6d12f Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 1 Apr 2026 12:20:51 +0200 Subject: [PATCH] feat(cli): add login command and refactor shared helpers --- internal/agent/disk.go | 25 +++++ internal/cmd/daemon.go | 30 +----- internal/cmd/init.go | 14 +-- internal/cmd/login.go | 187 +++++++++++++++++++++++++++++++++ internal/cmd/root.go | 3 + internal/cmd/status.go | 55 ++++++++++ internal/cmd/stream_handler.go | 29 +++-- internal/cmd/version.go | 2 +- 8 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 internal/agent/disk.go create mode 100644 internal/cmd/login.go diff --git a/internal/agent/disk.go b/internal/agent/disk.go new file mode 100644 index 0000000..9064ad0 --- /dev/null +++ b/internal/agent/disk.go @@ -0,0 +1,25 @@ +package agent + +import ( + "io/fs" + "path/filepath" +) + +// DirSize returns the total size in bytes of all files under dir. +func DirSize(dir string) (int64, error) { + var size int64 + err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries + } + if !d.IsDir() { + info, err := d.Info() + if err != nil { + return nil + } + size += info.Size() + } + return nil + }) + return size, err +} diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 61ca65e..06634e4 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -344,22 +344,7 @@ func runDaemonStart() error { }() // Auto-shutdown after 30 min of idle (no HTTP requests) - go func() { - ticker := time.NewTicker(60 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if srv.IdleSince() > 30*time.Minute { - log.Printf("[%s] disk stream idle timeout (30m), shutting down", sr.TaskID[:8]) - cancelStreamTask(sr.TaskID) - return - } - } - } - }() + go startIdleGuard(ctx, srv, sr.TaskID) } // Wire: WS control actions (pause/cancel/stream pushed from server) @@ -437,7 +422,7 @@ func runDaemonStart() error { scanInterval = parsed } } - go runAutoScan(ctx, cfg, scanInterval) + go runAutoScan(ctx, cfg, scanInterval, agentClient) } // Start daemon (blocks) @@ -515,7 +500,7 @@ func formatSpeedLog(bps int64) string { } // runAutoScan runs a library scan + sync on a timer. -func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration) { +func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client) { log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath) // Run first scan after a short delay (let daemon stabilize) @@ -556,13 +541,6 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration) } // Sync to server - apiKey := cfg.Auth.APIKey - if apiKey == "" { - log.Printf("[auto-scan] no API key, skipping sync") - return - } - - ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version) items := library.BuildSyncItems(cache) if len(items) == 0 { log.Printf("[auto-scan] no items to sync") @@ -605,5 +583,3 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration) } } } - -// buildSyncItems moved to internal/library/sync.go as library.BuildSyncItems diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 2bbb521..9e7a8ca 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -360,18 +360,8 @@ func runInit(apiURLOverride string) error { fmt.Println() // Features summary - features := []string{} - if resp.Features.Torrent { - features = append(features, "Torrent") - } - if resp.Features.Debrid { - features = append(features, "Debrid") - } - if resp.Features.Usenet { - features = append(features, "Usenet") - } - if len(features) > 0 { - cyan.Printf(" Available: %s\n", strings.Join(features, ", ")) + if line := formatFeatures(resp.Features); line != "" { + cyan.Printf(" Available: %s\n", line) } if !installDaemon { diff --git a/internal/cmd/login.go b/internal/cmd/login.go new file mode 100644 index 0000000..6ecfd0a --- /dev/null +++ b/internal/cmd/login.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + + "github.com/charmbracelet/huh" + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" +) + +func newLoginCmd() *cobra.Command { + var apiURL string + + cmd := &cobra.Command{ + Use: "login", + Aliases: []string{"auth"}, + Short: "Authenticate with your torrentclaw account", + Long: `Log in to your torrentclaw account by opening the browser or pasting +your API key manually. Use this when your API key has expired, been +revoked, or you want to switch to a different account. + +Unlike 'unarr init', this command only updates your authentication +credentials — it does not modify your download directory, daemon +settings, or other configuration.`, + Example: ` unarr login + unarr login --api-url https://custom.server.com`, + RunE: func(cmd *cobra.Command, args []string) error { + return runLogin(apiURL) + }, + } + + cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)") + + return cmd +} + +func runLogin(apiURLOverride string) error { + if !isTerminal() { + return fmt.Errorf("interactive mode requires a terminal (use UNARR_API_KEY env var instead)") + } + + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + dim := color.New(color.FgHiBlack) + + fmt.Println() + bold.Println(" unarr login") + fmt.Println() + + cfg := loadConfig() + + // Determine API URL + apiURL := cfg.Auth.APIURL + if apiURLOverride != "" { + apiURL = apiURLOverride + } + if apiURL == "" { + apiURL = "https://torrentclaw.com" + } + + // ── Authenticate ──────────────────────────────────────────────── + + var apiKey string + + // Try browser-based auth first + fmt.Println(" Opening browser to connect your account...") + fmt.Println() + + browserKey, browserErr := browserAuth(apiURL) + if browserErr == nil && strings.HasPrefix(browserKey, "tc_") { + apiKey = browserKey + green.Println(" ✓ Connected via browser") + fmt.Println() + } else { + // Fallback to manual API key entry + if browserErr != nil { + dim.Printf(" Could not connect automatically: %s\n", browserErr) + } + fmt.Println(" Paste your API key instead:") + dim.Printf(" (get it from %s/profile?tab=apikey)\n", apiURL) + fmt.Println() + + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("API Key"). + Placeholder("tc_..."). + Value(&apiKey). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("API key is required") + } + if !strings.HasPrefix(s, "tc_") { + return fmt.Errorf("API key should start with tc_") + } + return nil + }), + ), + ).Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\n Login cancelled.") + return nil + } + return err + } + apiKey = strings.TrimSpace(apiKey) + } + + // ── Validate API key ──────────────────────────────────────────── + + fmt.Print(" Verifying API key... ") + + agentID := cfg.Agent.ID + if agentID == "" { + agentID = uuid.New().String() + } + + hostname, _ := os.Hostname() + agentName := cfg.Agent.Name + if agentName == "" { + agentName = hostname + } + + ac := agent.NewClient(apiURL, apiKey, "unarr/"+Version) + resp, err := ac.Register(context.Background(), agent.RegisterRequest{ + AgentID: agentID, + Name: agentName, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Version: Version, + DownloadDir: cfg.Download.Dir, + }) + if err != nil { + color.Red("FAILED") + fmt.Println() + return fmt.Errorf("API key validation failed: %w", err) + } + + green.Println("OK") + fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) + fmt.Println() + + // ── Save config (auth fields only) ────────────────────────────── + + cfg.Auth.APIKey = apiKey + cfg.Auth.APIURL = apiURL + cfg.Agent.ID = agentID + cfg.Agent.Name = agentName + + configPath := config.FilePath() + if cfgFile != "" { + configPath = cfgFile + } + + if err := config.Save(cfg, configPath); err != nil { + return fmt.Errorf("save config: %w", err) + } + appCfg = cfg + + fmt.Println() + green.Println(" ✓ Credentials saved!") + fmt.Printf(" Config: %s\n", configPath) + fmt.Println() + + // Features summary + if line := formatFeatures(resp.Features); line != "" { + color.New(color.FgCyan).Printf(" Available: %s\n", line) + fmt.Println() + } + + if cfg.Download.Dir == "" { + fmt.Println(" Run " + bold.Sprint("unarr init") + " to complete the setup (download directory, daemon).") + fmt.Println() + } + + return nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 998c58b..b9b3d65 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -64,6 +64,8 @@ Source: https://github.com/torrentclaw/unarr`, // Getting Started initCmd := newInitCmd() initCmd.GroupID = "start" + loginCmd := newLoginCmd() + loginCmd.GroupID = "start" configCmd := newConfigCmd() configCmd.GroupID = "start" migrateCmd := newMigrateCmd() @@ -118,6 +120,7 @@ Source: https://github.com/torrentclaw/unarr`, rootCmd.AddCommand( // Getting Started initCmd, + loginCmd, configCmd, migrateCmd, // Search & Discovery diff --git a/internal/cmd/status.go b/internal/cmd/status.go index e90354c..5b451a5 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -49,6 +49,43 @@ func runStatus() error { 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 { @@ -81,6 +118,9 @@ func runStatus() error { 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!") } @@ -163,6 +203,21 @@ func isDaemonAlive(state *agent.DaemonState) bool { 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 diff --git a/internal/cmd/stream_handler.go b/internal/cmd/stream_handler.go index 7a2705a..def74ab 100644 --- a/internal/cmd/stream_handler.go +++ b/internal/cmd/stream_handler.go @@ -14,6 +14,24 @@ import ( "github.com/torrentclaw/unarr/internal/ui" ) +// startIdleGuard monitors a stream server and cancels the task after 30 minutes of inactivity. +func startIdleGuard(ctx context.Context, srv *engine.StreamServer, taskID string) { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if srv.IdleSince() > 30*time.Minute { + log.Printf("[%s] stream idle timeout (30m no HTTP requests), shutting down", taskID[:8]) + cancelStreamTask(taskID) + return + } + } + } +} + // streamRegistry tracks active stream tasks and servers for cancellation. var streamRegistry = struct { mu sync.Mutex @@ -127,12 +145,11 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine go watchReporter.Run(ctx) } - // 6. Unified progress + idle timeout loop + // 6. Start idle guard + progress loop + go startIdleGuard(ctx, srv, at.ID) eng.StartProgressLoop(ctx) progressTicker := time.NewTicker(3 * time.Second) defer progressTicker.Stop() - idleCheck := time.NewTicker(60 * time.Second) - defer idleCheck.Stop() completed := false for { @@ -141,12 +158,6 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine log.Printf("[%s] stream stopped", at.ID[:8]) return - 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{ diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 0f63091..40efa75 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.3.4-dev" +var Version = "0.4.0"