unarr/internal/cmd/config_menu.go
Deivid Soto c7ee0c0a28 feat(downloads): ordered preferred_methods list honored for web tasks
The agent ignored its config.toml method preference for web-driven downloads
(only the local `unarr download` command read it), and resolveMethod tried
torrent first in auto mode — so a 'debrid only' user still got torrent tasks.

- config: preferred_methods (ordered list, e.g. ["debrid","usenet"]) with
  MethodOrder() resolution; back-compat with the singular preferred_method.
  Methods absent from the list are disabled (debrid-only never torrents).
- resolveMethod/tryFallback honor the config order (gating, no fallback to a
  method outside the list) over the per-task preference.
- report preferred_methods on register so the web honors it (resolves debrid,
  gates the P2P stream fallback).
- enable the usenet downloader when usenet is listed (it was never enabled).
- config_menu: ordered presets (debrid-only, debrid→torrent, debrid→usenet…).

Tests: resolveMethod gating + fallback within/outside the list.
2026-06-14 12:51:32 +02:00

418 lines
12 KiB
Go

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"
}
// Method preference is an ordered list (PreferredMethods). The menu exposes
// the common presets as a single choice; custom orders can still be hand-set
// in config.toml. Derive the current preset from the effective order.
methodPreset := "auto"
switch strings.Join(cfg.Download.MethodOrder(), ",") {
case "torrent":
methodPreset = "torrent"
case "debrid":
methodPreset = "debrid"
case "usenet":
methodPreset = "usenet"
case "debrid,torrent":
methodPreset = "debrid,torrent"
case "debrid,usenet":
methodPreset = "debrid,usenet"
}
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").
Description("Methods not listed are disabled (e.g. debrid-only never uses torrent)").
Options(
huh.NewOption("Auto (web decides — 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"),
huh.NewOption("Debrid, then torrent", "debrid,torrent"),
huh.NewOption("Debrid, then usenet (requires Pro)", "debrid,usenet"),
).
Value(&methodPreset),
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
}
// Persist the preset as the ordered list (source of truth). "auto" clears the
// list; legacy PreferredMethod is kept in sync so an old reader still works.
if methodPreset == "auto" {
cfg.Download.PreferredMethods = nil
cfg.Download.PreferredMethod = "auto"
} else {
cfg.Download.PreferredMethods = strings.Split(methodPreset, ",")
cfg.Download.PreferredMethod = cfg.Download.PreferredMethods[0]
}
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),
huh.NewConfirm().
Title("Cache subtitles during scan?").
Description("Extract embedded text subtitles to WebVTT once during the scan and store them\nbeside the media (hidden .unarr dir) so playback subtitles are instant — and huge\nremuxes don't time out extracting on demand. Local only; nothing is uploaded.").
Value(&cfg.Library.CacheSubtitles),
huh.NewConfirm().
Title("Cache thumbnails during scan?").
Description("Pre-extract a few preview frames per file (hidden .unarr dir) so the file panel\nand seekbar previews load instantly. Small optimized JPEGs; local only.").
Value(&cfg.Library.CacheThumbnails),
),
).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
}