package cmd import ( "errors" "fmt" "reflect" "strconv" "strings" "time" "github.com/charmbracelet/huh" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/torrentclaw/unarr/internal/config" ) var configCategories = []string{"downloads", "organization", "library", "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 library Library scan settings and file deletion permissions 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("Library — scan settings & file deletion", "library"), 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 "library": return configLibrary(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" } validQualities := map[string]bool{"": true, "720p": true, "1080p": true, "2160p": true} if !validQualities[cfg.Download.PreferredQuality] { cfg.Download.PreferredQuality = "" } 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("Preferred quality"). Description("Hint for automatic torrent selection"). Options( huh.NewOption("Any (best available)", ""), huh.NewOption("720p", "720p"), huh.NewOption("1080p", "1080p"), huh.NewOption("2160p (4K)", "2160p"), ). Value(&cfg.Download.PreferredQuality), 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 configLibrary(cfg *config.Config) error { return huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Allow file deletion from web UI?"). Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered."). Value(&cfg.Library.AllowDelete), ), ).Run() } func configAdvanced(_ *config.Config) error { // Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed. fmt.Println("No advanced settings to configure. Sync intervals are automatic.") return nil } // ── 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 }