From 0b6c6849b14fa06265f75166cfdb653a7e9e25ed Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 29 Mar 2026 12:09:03 +0200 Subject: [PATCH] feat: replace setup with init wizard + interactive config menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `unarr init` (alias: `unarr setup`): streamlined 3-step wizard (API key, download dir, daemon install). Removed method/name prompts — auto-configured from defaults. - `unarr config [category]`: interactive menu with 7 categories (downloads, organization, notifications, device, region, connection, advanced). Direct access via `unarr config downloads`, etc. - Extract shared helpers (openBrowser, expandHome, isTerminal) to helpers.go. Delete old setup.go and config.go. - Update all "unarr setup" references to "unarr init" across daemon, doctor, status, README, install scripts. --- CHANGELOG.md | 3 +- README.md | 8 +- install.ps1 | 4 +- install.sh | 6 +- internal/cmd/config.go | 118 ---------- internal/cmd/config_menu.go | 367 +++++++++++++++++++++++++++++ internal/cmd/daemon.go | 8 +- internal/cmd/doctor.go | 8 +- internal/cmd/helpers.go | 56 +++++ internal/cmd/{setup.go => init.go} | 189 +++++++-------- internal/cmd/root.go | 8 +- internal/cmd/status.go | 2 +- internal/engine/stream_server.go | 12 + 13 files changed, 541 insertions(+), 248 deletions(-) delete mode 100644 internal/cmd/config.go create mode 100644 internal/cmd/config_menu.go create mode 100644 internal/cmd/helpers.go rename internal/cmd/{setup.go => init.go} (52%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4306bf2..4e0aee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`) +- Interactive config menu with 7 categories (`unarr config [category]`) - Clean command to remove temp files, logs, and cached data (`unarr clean`) - Daemon mode with background download management (`unarr start`) - One-shot download command (`unarr download`) - Stream to media player (`unarr stream`) -- Setup wizard for first-time configuration (`unarr setup`) - Doctor command for diagnostics (`unarr doctor`) - Status command for daemon monitoring (`unarr status`) - Download engine with torrent support (debrid and usenet coming soon) diff --git a/README.md b/README.md index f09d308..44a06cc 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ make build ## Quick Start ```bash -# 1. Run the setup wizard (opens browser for API key) -unarr setup +# 1. Run the init wizard (opens browser for API key) +unarr init # 2. Search for content unarr search "breaking bad" --type show --quality 1080p @@ -69,8 +69,8 @@ unarr start | Command | Description | |---------|-------------| -| `unarr setup` | First-time configuration wizard (API key, download dir, method) | -| `unarr config` | Edit configuration interactively | +| `unarr init` | First-time configuration wizard (API key, download dir, daemon) | +| `unarr config` | Edit all settings interactively (speed, organization, etc.) | ### Search & Discovery diff --git a/install.ps1 b/install.ps1 index d65e427..cd0754f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -153,7 +153,7 @@ function Install-Docker { Write-Host " mkdir `$env:APPDATA\unarr" Write-Host "" Write-Host " # 2. Run setup (interactive)" - Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr setup" + Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr init" Write-Host "" Write-Host " # 3. Start daemon" Write-Host " docker run -d --name unarr --restart unless-stopped ``" @@ -238,7 +238,7 @@ function Main { Install-Binary Write-Host "" Write-Host " Run " -NoNewline - Write-Host "unarr setup" -ForegroundColor White -NoNewline + Write-Host "unarr init" -ForegroundColor White -NoNewline Write-Host " to get started." Write-Host "" } diff --git a/install.sh b/install.sh index 8864a7f..7be4e81 100755 --- a/install.sh +++ b/install.sh @@ -204,7 +204,7 @@ install_docker() { # 2. Run setup (interactive) docker run -it --rm \ -v ~/.config/unarr:/config \ - torrentclaw/unarr setup + torrentclaw/unarr init # 3. Start daemon docker run -d --name unarr \ @@ -310,7 +310,7 @@ main() { case "$METHOD" in binary) install_binary - printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n" + printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n" ;; docker) install_docker @@ -322,7 +322,7 @@ main() { info "Installing via Homebrew..." brew install torrentclaw/tap/unarr ok "Installed via Homebrew" - printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n" + printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n" ;; *) error "Unknown method: $METHOD" diff --git a/internal/cmd/config.go b/internal/cmd/config.go deleted file mode 100644 index bcd1d97..0000000 --- a/internal/cmd/config.go +++ /dev/null @@ -1,118 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/fatih/color" - "github.com/spf13/cobra" - "github.com/torrentclaw/torrentclaw-cli/internal/config" -) - -func newConfigCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "config", - Short: "Edit configuration interactively", - Long: `Edit unarr settings interactively in your terminal. - -Prompts for API URL, API key, and default country. Press Enter to keep -the current value. For first-time setup use 'unarr setup' instead. - -Config file: ~/.config/unarr/config.toml -Environment variables override config file values: - UNARR_API_KEY API key - UNARR_API_URL API URL - UNARR_COUNTRY Default country code - UNARR_DOWNLOAD_DIR Download directory`, - Example: ` unarr config - unarr config --config /path/to/config.toml`, - RunE: func(cmd *cobra.Command, args []string) error { - return runConfig() - }, - } - - return cmd -} - -func runConfig() error { - if !isTerminal() { - return fmt.Errorf("interactive config requires a terminal (use --api-key flag or env vars instead)") - } - - reader := bufio.NewReader(os.Stdin) - bold := color.New(color.Bold) - green := color.New(color.FgGreen) - - cfg := loadConfig() - - fmt.Println() - bold.Println(" unarr Configuration") - fmt.Println() - - // API URL - currentURL := cfg.Auth.APIURL - fmt.Printf(" API URL [%s]: ", currentURL) - apiURL, _ := reader.ReadString('\n') - apiURL = strings.TrimSpace(apiURL) - if apiURL == "" { - apiURL = currentURL - } - - // API Key - currentKey := cfg.Auth.APIKey - keyDisplay := "" - if currentKey != "" { - if len(currentKey) > 8 { - keyDisplay = currentKey[:8] + "..." - } else { - keyDisplay = currentKey - } - } - fmt.Printf(" API Key [%s]: ", keyDisplay) - apiKey, _ := reader.ReadString('\n') - apiKey = strings.TrimSpace(apiKey) - if apiKey == "" { - apiKey = currentKey - } - - // Country - currentCountry := cfg.General.Country - fmt.Printf(" Default country [%s]: ", currentCountry) - country, _ := reader.ReadString('\n') - country = strings.TrimSpace(country) - if country == "" { - country = currentCountry - } - - // Apply changes - cfg.Auth.APIURL = apiURL - cfg.Auth.APIKey = apiKey - cfg.General.Country = country - - // Save - configPath := config.FilePath() - if cfgFile != "" { - configPath = cfgFile - } - - if err := config.Save(cfg, configPath); err != nil { - return fmt.Errorf("could not save config: %w", err) - } - - fmt.Println() - green.Printf(" Configuration saved to %s\n", configPath) - fmt.Println() - - return nil -} - -// isTerminal checks if stdin is a terminal. -func isTerminal() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go new file mode 100644 index 0000000..66139f3 --- /dev/null +++ b/internal/cmd/config_menu.go @@ -0,0 +1,367 @@ +package cmd + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/huh" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/torrentclaw/torrentclaw-cli/internal/config" +) + +var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"} + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config [category]", + Short: "Edit settings interactively", + Long: `Edit unarr settings interactively with a category-based menu. + +Categories: + downloads Download directory, method, speed limits, concurrency + organization Auto-sort into Movies / TV Shows folders + notifications Desktop notifications + device Agent name + region Country and language + connection API URL, API key + advanced Daemon poll & heartbeat intervals + +Run without arguments to see the full menu, or specify a category +to jump directly to it. + +Config file: ~/.config/unarr/config.toml +Environment variables override config file values: + UNARR_API_KEY API key + UNARR_API_URL API URL + UNARR_COUNTRY Default country code + UNARR_DOWNLOAD_DIR Download directory`, + Example: ` unarr config # Interactive menu + unarr config downloads # Jump to downloads settings + unarr config region # Jump to region settings`, + Args: cobra.MaximumNArgs(1), + ValidArgs: configCategories, + RunE: func(cmd *cobra.Command, args []string) error { + category := "" + if len(args) == 1 { + category = args[0] + } + return runConfigMenu(category) + }, + } + + return cmd +} + +func runConfigMenu(category string) error { + if !isTerminal() { + return fmt.Errorf("interactive config requires a terminal (use UNARR_* env vars instead)") + } + + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + dim := color.New(color.FgHiBlack) + + cfg := loadConfig() + original := cfg // snapshot for change detection + + fmt.Println() + bold.Println(" unarr config") + fmt.Println() + + // Direct category access + if category != "" { + if err := runCategory(&cfg, category); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\n Cancelled.") + return nil + } + return err + } + return saveIfChanged(cfg, original, green, dim) + } + + // Menu loop + for { + var choice string + err := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Settings"). + Options( + huh.NewOption("Downloads — directory, method, speed limits", "downloads"), + huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"), + huh.NewOption("Notifications — desktop notifications", "notifications"), + huh.NewOption("Device — agent name", "device"), + huh.NewOption("Region — country & language", "region"), + huh.NewOption("Connection — API URL & key", "connection"), + huh.NewOption("Advanced — daemon intervals", "advanced"), + huh.NewOption("Done — save & exit", "done"), + ). + Value(&choice), + ), + ).Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return saveIfChanged(cfg, original, green, dim) + } + return err + } + + if choice == "done" { + return saveIfChanged(cfg, original, green, dim) + } + + if err := runCategory(&cfg, choice); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + continue // back to menu + } + return err + } + } +} + +func runCategory(cfg *config.Config, category string) error { + switch category { + case "downloads": + return configDownloads(cfg) + case "organization": + return configOrganization(cfg) + case "notifications": + return configNotifications(cfg) + case "device": + return configDevice(cfg) + case "region": + return configRegion(cfg) + case "connection": + return configConnection(cfg) + case "advanced": + return configAdvanced(cfg) + default: + return fmt.Errorf("unknown category %q — valid: %s", category, strings.Join(configCategories, ", ")) + } +} + +func configDownloads(cfg *config.Config) error { + concurrent := strconv.Itoa(cfg.Download.MaxConcurrent) + validConcurrent := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true, "6": true, "8": true, "10": true} + if !validConcurrent[concurrent] { + concurrent = "3" + } + + validMethods := map[string]bool{"auto": true, "torrent": true, "debrid": true, "usenet": true} + if !validMethods[cfg.Download.PreferredMethod] { + cfg.Download.PreferredMethod = "auto" + } + + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Download directory"). + Value(&cfg.Download.Dir), + huh.NewSelect[string](). + Title("Preferred method"). + Options( + huh.NewOption("Auto (torrent + debrid when available)", "auto"), + huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"), + huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"), + huh.NewOption("Usenet only (requires Pro)", "usenet"), + ). + Value(&cfg.Download.PreferredMethod), + huh.NewSelect[string](). + Title("Max concurrent downloads"). + Options( + huh.NewOption("1", "1"), + huh.NewOption("2", "2"), + huh.NewOption("3 (default)", "3"), + huh.NewOption("4", "4"), + huh.NewOption("5", "5"), + huh.NewOption("6", "6"), + huh.NewOption("8", "8"), + huh.NewOption("10", "10"), + ). + Value(&concurrent), + huh.NewInput(). + Title("Max download speed"). + Description("0 = unlimited. Examples: 10MB, 500KB"). + Value(&cfg.Download.MaxDownloadSpeed). + Validate(validateSpeed), + huh.NewInput(). + Title("Max upload speed"). + Description("0 = unlimited. Examples: 1MB, 500KB"). + Value(&cfg.Download.MaxUploadSpeed). + Validate(validateSpeed), + ), + ).Run() + if err != nil { + return err + } + + cfg.Download.Dir = expandHome(strings.TrimSpace(cfg.Download.Dir)) + n, _ := strconv.Atoi(concurrent) + if n > 0 { + cfg.Download.MaxConcurrent = n + } + return nil +} + +func configOrganization(cfg *config.Config) error { + err := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Auto-organize downloads?"). + Description("Sort files into Movies and TV Shows subdirectories"). + Value(&cfg.Organize.Enabled), + huh.NewInput(). + Title("Movies directory"). + Value(&cfg.Organize.MoviesDir), + huh.NewInput(). + Title("TV Shows directory"). + Value(&cfg.Organize.TVShowsDir), + ), + ).Run() + if err != nil { + return err + } + cfg.Organize.MoviesDir = expandHome(strings.TrimSpace(cfg.Organize.MoviesDir)) + cfg.Organize.TVShowsDir = expandHome(strings.TrimSpace(cfg.Organize.TVShowsDir)) + return nil +} + +func configNotifications(cfg *config.Config) error { + return huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Desktop notifications?"). + Description("Show a notification when a download completes"). + Value(&cfg.Notifications.Enabled), + ), + ).Run() +} + +func configDevice(cfg *config.Config) error { + dim := color.New(color.FgHiBlack) + if cfg.Agent.ID != "" { + dim.Printf(" Agent ID: %s\n\n", cfg.Agent.ID) + } + + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Agent name"). + Description("Shown in the web dashboard"). + Value(&cfg.Agent.Name), + ), + ).Run() +} + +func configRegion(cfg *config.Config) error { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Country"). + Description("ISO code for streaming providers (US, ES, DE, GB...)"). + Placeholder("US"). + Value(&cfg.General.Country), + huh.NewInput(). + Title("Locale"). + Description("Language for content metadata (en, es, de, fr...)"). + Placeholder("en"). + Value(&cfg.General.Locale), + ), + ).Run() +} + +func configConnection(cfg *config.Config) error { + keyDesc := "Current: (none)" + if k := cfg.Auth.APIKey; len(k) > 8 { + keyDesc = "Current: " + k[:8] + "..." + } + + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("API URL"). + Value(&cfg.Auth.APIURL), + huh.NewInput(). + Title("API Key"). + Description(keyDesc). + EchoMode(huh.EchoModePassword). + Value(&cfg.Auth.APIKey), + ), + ).Run() +} + +func configAdvanced(cfg *config.Config) error { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Poll interval"). + Description("How often to check for new tasks (e.g. 30s, 1m)"). + Value(&cfg.Daemon.PollInterval). + Validate(validateDuration), + huh.NewInput(). + Title("Heartbeat interval"). + Description("How often to send heartbeat to server (e.g. 30s, 1m)"). + Value(&cfg.Daemon.HeartbeatInterval). + Validate(validateDuration), + ), + ).Run() +} + +// ── Validators ────────────────────────────────────────────────────── + +func validateSpeed(s string) error { + s = strings.TrimSpace(s) + if s == "" || s == "0" { + return nil + } + if _, err := config.ParseSpeed(s); err != nil { + return fmt.Errorf("invalid speed: %s (e.g. 10MB, 500KB, 0)", s) + } + return nil +} + +func validateDuration(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration: %s (e.g. 30s, 1m, 5m)", s) + } + return nil +} + +// ── Save logic ────────────────────────────────────────────────────── + +func saveIfChanged(cfg, original config.Config, green, dim *color.Color) error { + if reflect.DeepEqual(cfg, original) { + dim.Println(" No changes made.") + fmt.Println() + return nil + } + + if err := cfg.ValidatePaths(); err != nil { + return fmt.Errorf("unsafe configuration: %w", err) + } + + configPath := config.FilePath() + if cfgFile != "" { + configPath = cfgFile + } + + if err := config.Save(cfg, configPath); err != nil { + return fmt.Errorf("save config: %w", err) + } + appCfg = cfg // update cached config so subsequent calls see the new values + + fmt.Println() + green.Printf(" ✓ Configuration saved to %s\n", configPath) + fmt.Println() + return nil +} diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 1f34f64..0eb56bf 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -35,7 +35,7 @@ The daemon sends periodic heartbeats and reports download progress back to the web dashboard. Press Ctrl+C to stop gracefully — active downloads get up to 30 seconds to finish. -Requires: API key, agent ID, and download directory (run 'unarr setup' first). +Requires: API key, agent ID, and download directory (run 'unarr init' first). To run as a background service, use 'unarr daemon install' instead.`, Example: ` unarr start @@ -99,13 +99,13 @@ func runDaemonStart() error { // Validate config if cfg.Auth.APIKey == "" { - return fmt.Errorf("no API key configured — run 'unarr setup' first") + return fmt.Errorf("no API key configured — run 'unarr init' first") } if cfg.Agent.ID == "" { - return fmt.Errorf("no agent ID — run 'unarr setup' first") + return fmt.Errorf("no agent ID — run 'unarr init' first") } if cfg.Download.Dir == "" { - return fmt.Errorf("no download directory — run 'unarr setup' first") + return fmt.Errorf("no download directory — run 'unarr init' first") } // Validate configured paths are safe diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index e20e9c9..c8b06c9 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -84,7 +84,7 @@ func runDoctor() error { path = cfgFile } if _, err := os.Stat(path); os.IsNotExist(err) { - return path + " (not found, run unarr setup)", fmt.Errorf("missing") + return path + " (not found, run unarr init)", fmt.Errorf("missing") } return path, nil }) @@ -95,7 +95,7 @@ func runDoctor() error { key = cfg.Auth.APIKey } if key == "" { - return "run unarr setup to configure", fmt.Errorf("missing") + return "run unarr init to configure", fmt.Errorf("missing") } if len(key) > 8 { return key[:8] + "...", nil @@ -130,7 +130,7 @@ func runDoctor() error { return "no API key", fmt.Errorf("skipped") } if cfg.Agent.ID == "" { - return "no agent ID, run unarr setup", fmt.Errorf("not registered") + return "no agent ID, run unarr init", fmt.Errorf("not registered") } ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version) @@ -155,7 +155,7 @@ func runDoctor() error { check("Download directory", func() (string, error) { dir := cfg.Download.Dir if dir == "" { - return "not configured, run unarr setup", fmt.Errorf("missing") + return "not configured, run unarr init", fmt.Errorf("missing") } fi, err := os.Stat(dir) if os.IsNotExist(err) { diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go new file mode 100644 index 0000000..c51e985 --- /dev/null +++ b/internal/cmd/helpers.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// openBrowser opens a URL in the default browser. +func openBrowser(url string) { + var c *exec.Cmd + switch runtime.GOOS { + case "darwin": + c = exec.Command("open", url) + case "windows": + c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: // linux, freebsd + c = exec.Command("xdg-open", url) + } + _ = c.Start() // fire and forget; best-effort +} + +// defaultDownloadDir returns a sensible default download directory. +func defaultDownloadDir() string { + home, _ := os.UserHomeDir() + candidates := []string{ + filepath.Join(home, "Media"), + filepath.Join(home, "Downloads", "unarr"), + } + for _, d := range candidates { + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + return d + } + } + return filepath.Join(home, "Media") +} + +// expandHome expands a leading ~/ to the user's home directory. +func expandHome(path string) string { + if strings.HasPrefix(path, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, path[2:]) + } + return path +} + +// isTerminal checks if stdin is a terminal (not piped). +func isTerminal() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/cmd/setup.go b/internal/cmd/init.go similarity index 52% rename from internal/cmd/setup.go rename to internal/cmd/init.go index 3b55b72..8e7b3c7 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/init.go @@ -2,9 +2,9 @@ package cmd import ( "context" + "errors" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -17,25 +17,25 @@ import ( "github.com/torrentclaw/torrentclaw-cli/internal/config" ) -func newSetupCmd() *cobra.Command { +func newInitCmd() *cobra.Command { var apiURL string cmd := &cobra.Command{ - Use: "setup", - Short: "First-time configuration wizard", - Long: `Interactive setup that configures API key, download directory, and -preferred download method. + Use: "init", + Aliases: []string{"setup"}, + Short: "First-time configuration wizard", + Long: `Interactive setup that connects your account, picks a download directory, +and optionally installs the daemon as a background service. -Opens your browser to create/copy your API key, then walks you through -choosing a download directory, method (torrent, debrid, usenet), and -device name. Validates the API key against the server before saving. +Opens your browser to create/copy your API key, validates it against the +server, and saves your configuration. -Run this once after installing unarr. To change settings later, +Run this once after installing unarr. To customize settings later, use 'unarr config' or edit ~/.config/unarr/config.toml directly.`, - Example: ` unarr setup - unarr setup --api-url https://custom.server.com`, + Example: ` unarr init + unarr init --api-url https://custom.server.com`, RunE: func(cmd *cobra.Command, args []string) error { - return runSetup(apiURL) + return runInit(apiURL) }, } @@ -44,13 +44,17 @@ use 'unarr config' or edit ~/.config/unarr/config.toml directly.`, return cmd } -func runSetup(apiURLOverride string) error { +func runInit(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) cyan := color.New(color.FgCyan) fmt.Println() - bold.Println(" unarr Setup") + bold.Println(" unarr init") fmt.Println() cfg := loadConfig() @@ -64,18 +68,18 @@ func runSetup(apiURLOverride string) error { apiURL = "https://torrentclaw.com" } - // Open browser to API keys page + // ── Step 1/3: Connect account ─────────────────────────────────── + keysURL := apiURL + "/profile?tab=apikey" fmt.Printf(" Opening %s ...\n", keysURL) openBrowser(keysURL) fmt.Println() - // Step 1: API Key apiKey := cfg.Auth.APIKey err := huh.NewForm( huh.NewGroup( huh.NewInput(). - Title("API Key"). + Title("Step 1/3 — API Key"). Description("Copy it from the page that just opened in your browser"). Placeholder("tc_..."). Value(&apiKey). @@ -92,6 +96,10 @@ func runSetup(apiURLOverride string) error { ), ).Run() if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\n Init cancelled.") + return nil + } return err } apiKey = strings.TrimSpace(apiKey) @@ -129,7 +137,8 @@ func runSetup(apiURLOverride string) error { fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) fmt.Println() - // Step 2: Download directory + // ── Step 2/3: Download directory ──────────────────────────────── + downloadDir := cfg.Download.Dir if downloadDir == "" { downloadDir = defaultDownloadDir() @@ -137,72 +146,53 @@ func runSetup(apiURLOverride string) error { err = huh.NewForm( huh.NewGroup( huh.NewInput(). - Title("Download Directory"). + Title("Step 2/3 — Download Directory"). Description("Where should downloaded files be saved?"). Value(&downloadDir), ), ).Run() if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\n Init cancelled.") + return nil + } return err } downloadDir = expandHome(strings.TrimSpace(downloadDir)) - // Step 3: Preferred download method - method := cfg.Download.PreferredMethod - if method == "" { - method = "auto" - } - - methodOptions := []huh.Option[string]{ - huh.NewOption("Auto (torrent, debrid when available)", "auto"), - huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"), - } - if resp.Features.Debrid { - methodOptions = append(methodOptions, - huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"), - ) - } - if resp.Features.Usenet { - methodOptions = append(methodOptions, - huh.NewOption("Usenet only (requires Pro)", "usenet"), - ) - } + // ── Step 3/3: Install daemon ──────────────────────────────────── + var installDaemon bool err = huh.NewForm( huh.NewGroup( - huh.NewSelect[string](). - Title("Download Method"). - Description("How do you want to download?"). - Options(methodOptions...). - Value(&method), + huh.NewConfirm(). + Title("Step 3/3 — Install background service?"). + Description("Starts unarr automatically on boot (systemd/launchd)"). + Affirmative("Yes, install and start"). + Negative("No, I'll run it manually"). + Value(&installDaemon), ), ).Run() if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\n Init cancelled.") + return nil + } return err } - // Step 4: Agent name - err = huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Device Name"). - Description("A name for this machine (shown in the web dashboard)"). - Value(&agentName), - ), - ).Run() - if err != nil { - return err - } + // ── Save config ───────────────────────────────────────────────── - // Save config cfg.Auth.APIKey = apiKey cfg.Auth.APIURL = apiURL cfg.Agent.ID = agentID - cfg.Agent.Name = strings.TrimSpace(agentName) + cfg.Agent.Name = agentName cfg.Download.Dir = downloadDir - cfg.Download.PreferredMethod = method - // Set organize dirs based on download dir + if cfg.Download.PreferredMethod == "" { + cfg.Download.PreferredMethod = "auto" + } + if cfg.Organize.MoviesDir == "" { cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies") } @@ -210,7 +200,6 @@ func runSetup(apiURLOverride string) error { cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows") } - // Validate paths before saving if err := cfg.ValidatePaths(); err != nil { return fmt.Errorf("unsafe configuration: %w", err) } @@ -223,16 +212,27 @@ func runSetup(apiURLOverride string) error { if err := config.Save(cfg, configPath); err != nil { return fmt.Errorf("save config: %w", err) } + appCfg = cfg // update cached config so subsequent calls see the new values + + // ── Install daemon (if requested) ─────────────────────────────── + + if installDaemon { + fmt.Println() + if err := runDaemonInstall(); err != nil { + color.New(color.FgYellow).Printf(" Could not install daemon: %s\n", err) + fmt.Println() + fmt.Println(" You can install it later with: " + bold.Sprint("unarr daemon install")) + fmt.Println(" Or run manually with: " + bold.Sprint("unarr start")) + } + } + + // ── Summary ───────────────────────────────────────────────────── - // Summary fmt.Println() - green.Println(" Setup complete!") + green.Println(" ✓ unarr is ready!") fmt.Println() - fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) - fmt.Printf(" Downloads: %s\n", downloadDir) - fmt.Printf(" Method: %s\n", method) - fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...") - fmt.Printf(" Config: %s\n", configPath) + fmt.Printf(" Dashboard: %s/downloads\n", apiURL) + fmt.Printf(" Config: %s\n", configPath) fmt.Println() // Features summary @@ -246,46 +246,21 @@ func runSetup(apiURLOverride string) error { if resp.Features.Usenet { features = append(features, "Usenet") } - cyan.Printf(" Available: %s\n", strings.Join(features, ", ")) + if len(features) > 0 { + cyan.Printf(" Available: %s\n", strings.Join(features, ", ")) + } + + if !installDaemon { + fmt.Println() + fmt.Println(" Start the daemon:") + fmt.Println(" " + bold.Sprint("unarr start") + " foreground (Ctrl+C to stop)") + fmt.Println(" " + bold.Sprint("unarr daemon install") + " background service (auto-start on boot)") + } + fmt.Println() - fmt.Println(" Next: run", bold.Sprint("unarr start"), "to begin downloading") + fmt.Println(" Customize speed limits, notifications, and more:") + fmt.Println(" " + bold.Sprint("unarr config")) fmt.Println() return nil } - -// openBrowser opens a URL in the default browser. -func openBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: // linux, freebsd - cmd = exec.Command("xdg-open", url) - } - cmd.Start() // fire and forget -} - -func defaultDownloadDir() string { - home, _ := os.UserHomeDir() - candidates := []string{ - filepath.Join(home, "Media"), - filepath.Join(home, "Downloads", "unarr"), - } - for _, d := range candidates { - if fi, err := os.Stat(d); err == nil && fi.IsDir() { - return d - } - } - return filepath.Join(home, "Media") -} - -func expandHome(path string) string { - if strings.HasPrefix(path, "~/") { - home, _ := os.UserHomeDir() - return filepath.Join(home, path[2:]) - } - return path -} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7207cac..998bf5a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -32,7 +32,7 @@ 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 setup First-time configuration wizard + unarr init First-time configuration wizard unarr search "breaking bad" Search for content unarr start Start the download daemon @@ -62,8 +62,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`, rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output") // Getting Started - setupCmd := newSetupCmd() - setupCmd.GroupID = "start" + initCmd := newInitCmd() + initCmd.GroupID = "start" configCmd := newConfigCmd() configCmd.GroupID = "start" @@ -111,7 +111,7 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`, rootCmd.AddCommand( // Getting Started - setupCmd, + initCmd, configCmd, // Search & Discovery searchCmd, diff --git a/internal/cmd/status.go b/internal/cmd/status.go index bcd3144..0f989f4 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -33,7 +33,7 @@ func runStatus() error { cfg := loadConfig() if cfg.Auth.APIKey == "" { - dim.Println(" Not configured. Run 'unarr setup' first.") + dim.Println(" Not configured. Run 'unarr init' first.") fmt.Println() return nil } diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index cffaddd..a2c0f31 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -72,6 +72,7 @@ type diskFileProvider struct { func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser { f, err := os.Open(p.path) if err != nil { + log.Printf("stream: failed to open %q: %v", p.path, err) return nil } return f @@ -134,6 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error { } func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { + // CORS headers — allow web player from any origin (HTTPS site → localhost) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Range") + w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + reader := ss.provider.NewFileReader(r.Context()) if reader == nil { http.Error(w, "file not found", http.StatusNotFound)