feat: replace setup with init wizard + interactive config menu

- `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.
This commit is contained in:
Deivid Soto 2026-03-29 12:09:03 +02:00
parent 35e5298f23
commit 0b6c6849b1
13 changed files with 541 additions and 248 deletions

View file

@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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`) - Clean command to remove temp files, logs, and cached data (`unarr clean`)
- Daemon mode with background download management (`unarr start`) - Daemon mode with background download management (`unarr start`)
- One-shot download command (`unarr download`) - One-shot download command (`unarr download`)
- Stream to media player (`unarr stream`) - Stream to media player (`unarr stream`)
- Setup wizard for first-time configuration (`unarr setup`)
- Doctor command for diagnostics (`unarr doctor`) - Doctor command for diagnostics (`unarr doctor`)
- Status command for daemon monitoring (`unarr status`) - Status command for daemon monitoring (`unarr status`)
- Download engine with torrent support (debrid and usenet coming soon) - Download engine with torrent support (debrid and usenet coming soon)

View file

@ -53,8 +53,8 @@ make build
## Quick Start ## Quick Start
```bash ```bash
# 1. Run the setup wizard (opens browser for API key) # 1. Run the init wizard (opens browser for API key)
unarr setup unarr init
# 2. Search for content # 2. Search for content
unarr search "breaking bad" --type show --quality 1080p unarr search "breaking bad" --type show --quality 1080p
@ -69,8 +69,8 @@ unarr start
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `unarr setup` | First-time configuration wizard (API key, download dir, method) | | `unarr init` | First-time configuration wizard (API key, download dir, daemon) |
| `unarr config` | Edit configuration interactively | | `unarr config` | Edit all settings interactively (speed, organization, etc.) |
### Search & Discovery ### Search & Discovery

View file

@ -153,7 +153,7 @@ function Install-Docker {
Write-Host " mkdir `$env:APPDATA\unarr" Write-Host " mkdir `$env:APPDATA\unarr"
Write-Host "" Write-Host ""
Write-Host " # 2. Run setup (interactive)" 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 ""
Write-Host " # 3. Start daemon" Write-Host " # 3. Start daemon"
Write-Host " docker run -d --name unarr --restart unless-stopped ``" Write-Host " docker run -d --name unarr --restart unless-stopped ``"
@ -238,7 +238,7 @@ function Main {
Install-Binary Install-Binary
Write-Host "" Write-Host ""
Write-Host " Run " -NoNewline 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 " to get started."
Write-Host "" Write-Host ""
} }

View file

@ -204,7 +204,7 @@ install_docker() {
# 2. Run setup (interactive) # 2. Run setup (interactive)
docker run -it --rm \ docker run -it --rm \
-v ~/.config/unarr:/config \ -v ~/.config/unarr:/config \
torrentclaw/unarr setup torrentclaw/unarr init
# 3. Start daemon # 3. Start daemon
docker run -d --name unarr \ docker run -d --name unarr \
@ -310,7 +310,7 @@ main() {
case "$METHOD" in case "$METHOD" in
binary) binary)
install_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) docker)
install_docker install_docker
@ -322,7 +322,7 @@ main() {
info "Installing via Homebrew..." info "Installing via Homebrew..."
brew install torrentclaw/tap/unarr brew install torrentclaw/tap/unarr
ok "Installed via Homebrew" 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" error "Unknown method: $METHOD"

View file

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

367
internal/cmd/config_menu.go Normal file
View file

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

View file

@ -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 to the web dashboard. Press Ctrl+C to stop gracefully active downloads
get up to 30 seconds to finish. 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.`, To run as a background service, use 'unarr daemon install' instead.`,
Example: ` unarr start Example: ` unarr start
@ -99,13 +99,13 @@ func runDaemonStart() error {
// Validate config // Validate config
if cfg.Auth.APIKey == "" { 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 == "" { 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 == "" { 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 // Validate configured paths are safe

View file

@ -84,7 +84,7 @@ func runDoctor() error {
path = cfgFile path = cfgFile
} }
if _, err := os.Stat(path); os.IsNotExist(err) { 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 return path, nil
}) })
@ -95,7 +95,7 @@ func runDoctor() error {
key = cfg.Auth.APIKey key = cfg.Auth.APIKey
} }
if key == "" { if key == "" {
return "run unarr setup to configure", fmt.Errorf("missing") return "run unarr init to configure", fmt.Errorf("missing")
} }
if len(key) > 8 { if len(key) > 8 {
return key[:8] + "...", nil return key[:8] + "...", nil
@ -130,7 +130,7 @@ func runDoctor() error {
return "no API key", fmt.Errorf("skipped") return "no API key", fmt.Errorf("skipped")
} }
if cfg.Agent.ID == "" { 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) ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
@ -155,7 +155,7 @@ func runDoctor() error {
check("Download directory", func() (string, error) { check("Download directory", func() (string, error) {
dir := cfg.Download.Dir dir := cfg.Download.Dir
if 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) fi, err := os.Stat(dir)
if os.IsNotExist(err) { if os.IsNotExist(err) {

56
internal/cmd/helpers.go Normal file
View file

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

View file

@ -2,9 +2,9 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@ -17,25 +17,25 @@ import (
"github.com/torrentclaw/torrentclaw-cli/internal/config" "github.com/torrentclaw/torrentclaw-cli/internal/config"
) )
func newSetupCmd() *cobra.Command { func newInitCmd() *cobra.Command {
var apiURL string var apiURL string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "setup", Use: "init",
Short: "First-time configuration wizard", Aliases: []string{"setup"},
Long: `Interactive setup that configures API key, download directory, and Short: "First-time configuration wizard",
preferred download method. 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 Opens your browser to create/copy your API key, validates it against the
choosing a download directory, method (torrent, debrid, usenet), and server, and saves your configuration.
device name. Validates the API key against the server before saving.
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.`, use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
Example: ` unarr setup Example: ` unarr init
unarr setup --api-url https://custom.server.com`, unarr init --api-url https://custom.server.com`,
RunE: func(cmd *cobra.Command, args []string) error { 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 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) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan) cyan := color.New(color.FgCyan)
fmt.Println() fmt.Println()
bold.Println(" unarr Setup") bold.Println(" unarr init")
fmt.Println() fmt.Println()
cfg := loadConfig() cfg := loadConfig()
@ -64,18 +68,18 @@ func runSetup(apiURLOverride string) error {
apiURL = "https://torrentclaw.com" apiURL = "https://torrentclaw.com"
} }
// Open browser to API keys page // ── Step 1/3: Connect account ───────────────────────────────────
keysURL := apiURL + "/profile?tab=apikey" keysURL := apiURL + "/profile?tab=apikey"
fmt.Printf(" Opening %s ...\n", keysURL) fmt.Printf(" Opening %s ...\n", keysURL)
openBrowser(keysURL) openBrowser(keysURL)
fmt.Println() fmt.Println()
// Step 1: API Key
apiKey := cfg.Auth.APIKey apiKey := cfg.Auth.APIKey
err := huh.NewForm( err := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput(). huh.NewInput().
Title("API Key"). Title("Step 1/3 — API Key").
Description("Copy it from the page that just opened in your browser"). Description("Copy it from the page that just opened in your browser").
Placeholder("tc_..."). Placeholder("tc_...").
Value(&apiKey). Value(&apiKey).
@ -92,6 +96,10 @@ func runSetup(apiURLOverride string) error {
), ),
).Run() ).Run()
if err != nil { if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
}
return err return err
} }
apiKey = strings.TrimSpace(apiKey) 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.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println() fmt.Println()
// Step 2: Download directory // ── Step 2/3: Download directory ────────────────────────────────
downloadDir := cfg.Download.Dir downloadDir := cfg.Download.Dir
if downloadDir == "" { if downloadDir == "" {
downloadDir = defaultDownloadDir() downloadDir = defaultDownloadDir()
@ -137,72 +146,53 @@ func runSetup(apiURLOverride string) error {
err = huh.NewForm( err = huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput(). huh.NewInput().
Title("Download Directory"). Title("Step 2/3 — Download Directory").
Description("Where should downloaded files be saved?"). Description("Where should downloaded files be saved?").
Value(&downloadDir), Value(&downloadDir),
), ),
).Run() ).Run()
if err != nil { if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
}
return err return err
} }
downloadDir = expandHome(strings.TrimSpace(downloadDir)) downloadDir = expandHome(strings.TrimSpace(downloadDir))
// Step 3: Preferred download method // ── Step 3/3: Install daemon ────────────────────────────────────
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"),
)
}
var installDaemon bool
err = huh.NewForm( err = huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewSelect[string](). huh.NewConfirm().
Title("Download Method"). Title("Step 3/3 — Install background service?").
Description("How do you want to download?"). Description("Starts unarr automatically on boot (systemd/launchd)").
Options(methodOptions...). Affirmative("Yes, install and start").
Value(&method), Negative("No, I'll run it manually").
Value(&installDaemon),
), ),
).Run() ).Run()
if err != nil { if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
}
return err return err
} }
// Step 4: Agent name // ── Save config ─────────────────────────────────────────────────
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
cfg.Auth.APIKey = apiKey cfg.Auth.APIKey = apiKey
cfg.Auth.APIURL = apiURL cfg.Auth.APIURL = apiURL
cfg.Agent.ID = agentID cfg.Agent.ID = agentID
cfg.Agent.Name = strings.TrimSpace(agentName) cfg.Agent.Name = agentName
cfg.Download.Dir = downloadDir 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 == "" { if cfg.Organize.MoviesDir == "" {
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies") cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
} }
@ -210,7 +200,6 @@ func runSetup(apiURLOverride string) error {
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows") cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
} }
// Validate paths before saving
if err := cfg.ValidatePaths(); err != nil { if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err) return fmt.Errorf("unsafe configuration: %w", err)
} }
@ -223,16 +212,27 @@ func runSetup(apiURLOverride string) error {
if err := config.Save(cfg, configPath); err != nil { if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err) 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() fmt.Println()
green.Println(" Setup complete!") green.Println(" ✓ unarr is ready!")
fmt.Println() fmt.Println()
fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) fmt.Printf(" Dashboard: %s/downloads\n", apiURL)
fmt.Printf(" Downloads: %s\n", downloadDir) fmt.Printf(" Config: %s\n", configPath)
fmt.Printf(" Method: %s\n", method)
fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...")
fmt.Printf(" Config: %s\n", configPath)
fmt.Println() fmt.Println()
// Features summary // Features summary
@ -246,46 +246,21 @@ func runSetup(apiURLOverride string) error {
if resp.Features.Usenet { if resp.Features.Usenet {
features = append(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()
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() fmt.Println()
return nil 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
}

View file

@ -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. find streaming providers, and manage your media collection all from your terminal.
Get started: Get started:
unarr setup First-time configuration wizard unarr init First-time configuration wizard
unarr search "breaking bad" Search for content unarr search "breaking bad" Search for content
unarr start Start the download daemon 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") rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
// Getting Started // Getting Started
setupCmd := newSetupCmd() initCmd := newInitCmd()
setupCmd.GroupID = "start" initCmd.GroupID = "start"
configCmd := newConfigCmd() configCmd := newConfigCmd()
configCmd.GroupID = "start" configCmd.GroupID = "start"
@ -111,7 +111,7 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
rootCmd.AddCommand( rootCmd.AddCommand(
// Getting Started // Getting Started
setupCmd, initCmd,
configCmd, configCmd,
// Search & Discovery // Search & Discovery
searchCmd, searchCmd,

View file

@ -33,7 +33,7 @@ func runStatus() error {
cfg := loadConfig() cfg := loadConfig()
if cfg.Auth.APIKey == "" { if cfg.Auth.APIKey == "" {
dim.Println(" Not configured. Run 'unarr setup' first.") dim.Println(" Not configured. Run 'unarr init' first.")
fmt.Println() fmt.Println()
return nil return nil
} }

View file

@ -72,6 +72,7 @@ type diskFileProvider struct {
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser { func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
f, err := os.Open(p.path) f, err := os.Open(p.path)
if err != nil { if err != nil {
log.Printf("stream: failed to open %q: %v", p.path, err)
return nil return nil
} }
return f return f
@ -134,6 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
} }
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { 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()) reader := ss.provider.NewFileReader(r.Context())
if reader == nil { if reader == nil {
http.Error(w, "file not found", http.StatusNotFound) http.Error(w, "file not found", http.StatusNotFound)