// Package config loads and validates the arr-tui configuration file. package config import ( "bytes" "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "strings" ) // Service holds the connection settings of a single backend. type Service struct { URL string `json:"url"` APIKey string `json:"apikey"` } // Arr is a Radarr or Sonarr instance, with optional defaults for the add form. type Arr struct { Service DefaultQualityProfile string `json:"default_quality_profile,omitempty"` DefaultRootFolder string `json:"default_root_folder,omitempty"` } // UI holds interface settings. type UI struct { RefreshSeconds int `json:"refresh_seconds,omitempty"` } // Config is the full application configuration. type Config struct { Radarr Arr `json:"radarr"` Sonarr Arr `json:"sonarr"` SABnzbd Service `json:"sabnzbd"` UI UI `json:"ui"` } const defaultRefreshSeconds = 3 // DefaultPath returns $XDG_CONFIG_HOME/arr-tui/config.json, falling back to // ~/.config/arr-tui/config.json. func DefaultPath() (string, error) { dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { return "", err } dir = filepath.Join(home, ".config") } return filepath.Join(dir, "arr-tui", "config.json"), nil } // Load reads the config at path, applies environment overrides and defaults, // and validates the result. The returned warnings are non-fatal. func Load(path string) (*Config, []string, error) { data, err := os.ReadFile(path) if err != nil { return nil, nil, fmt.Errorf("reading config: %w", err) } var warnings []string if info, err := os.Stat(path); err == nil && info.Mode().Perm()&0o077 != 0 { warnings = append(warnings, fmt.Sprintf( "%s is accessible by other users (mode %04o); it contains API keys, consider chmod 600", path, info.Mode().Perm())) } cfg, err := Parse(data) if err != nil { return nil, nil, fmt.Errorf("%s: %w", path, err) } return cfg, warnings, nil } // Parse decodes config JSON, applies environment overrides and defaults, and // validates the result. Unknown fields are rejected to catch typos. func Parse(data []byte) (*Config, error) { var cfg Config dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } cfg.applyEnv(os.Getenv) cfg.applyDefaults() if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } func (c *Config) applyEnv(get func(string) string) { override := func(dst *string, key string) { if v := get(key); v != "" { *dst = v } } override(&c.Radarr.URL, "ARR_TUI_RADARR_URL") override(&c.Radarr.APIKey, "ARR_TUI_RADARR_APIKEY") override(&c.Sonarr.URL, "ARR_TUI_SONARR_URL") override(&c.Sonarr.APIKey, "ARR_TUI_SONARR_APIKEY") override(&c.SABnzbd.URL, "ARR_TUI_SABNZBD_URL") override(&c.SABnzbd.APIKey, "ARR_TUI_SABNZBD_APIKEY") } func (c *Config) applyDefaults() { if c.UI.RefreshSeconds <= 0 { c.UI.RefreshSeconds = defaultRefreshSeconds } c.Radarr.URL = strings.TrimRight(c.Radarr.URL, "/") c.Sonarr.URL = strings.TrimRight(c.Sonarr.URL, "/") c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/") } // Validate checks that every service has a usable URL and API key. func (c *Config) Validate() error { var errs []error for _, s := range []struct { name string svc Service }{ {"radarr", c.Radarr.Service}, {"sonarr", c.Sonarr.Service}, {"sabnzbd", c.SABnzbd}, } { if s.svc.APIKey == "" { errs = append(errs, fmt.Errorf("%s: apikey is required", s.name)) } u, err := url.Parse(s.svc.URL) switch { case s.svc.URL == "": errs = append(errs, fmt.Errorf("%s: url is required", s.name)) case err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "": errs = append(errs, fmt.Errorf("%s: url %q must be an http(s) URL", s.name, s.svc.URL)) } } return errors.Join(errs...) }