feat(cli): add login command and refactor shared helpers

This commit is contained in:
Deivid Soto 2026-04-01 12:20:51 +02:00
parent 0dafeaa70d
commit 4d35e197f0
8 changed files with 296 additions and 49 deletions

25
internal/agent/disk.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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 {

187
internal/cmd/login.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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{

View file

@ -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"