// 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"` // Covers shows poster thumbnails in the detail panes (default true). // Posters of titles that are not in the library yet are downloaded from // the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB). Covers *bool `json:"covers,omitempty"` // NotifyDownloads shows a popup (and logs it) when a SABnzbd download // finishes or fails, whichever view is open (default true). NotifyDownloads *bool `json:"notify_downloads,omitempty"` // Bell also rings the terminal bell for those notifications (default false). Bell bool `json:"bell,omitempty"` // CoverStyle selects the block characters posters are drawn with: // "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant" // (2x3, sharper, needs a font with the Symbols for Legacy Computing // block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through // the Kitty graphics protocol with Unicode placeholders: kitty and // Ghostty) or "auto" (default: the Kitty protocol where the terminal is // known to support it, else sextant on terminals known to draw them or when // fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty". CoverStyle string `json:"cover_style,omitempty"` } // ShouldNotifyDownloads reports whether finished downloads are announced. func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u.NotifyDownloads } // ShowCovers reports whether poster thumbnails are enabled. func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers } // 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) { raw, err := parseRaw(data) if err != nil { return nil, err } return raw.Effective(os.Getenv) } // parseRaw decodes config JSON as it stands in the file: no environment // overrides, no defaults, no validation. func parseRaw(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) } return &cfg, nil } // LoadFile reads the config at path exactly as written: no environment // overrides, no defaults, no validation. It is what an editor of the file must // start from, so values that only came from the environment are not written // back to disk. func LoadFile(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config: %w", err) } cfg, err := parseRaw(data) if err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return cfg, nil } // Effective returns the configuration the program runs with: a copy with the // environment overrides and defaults applied, validated. func (c *Config) Effective(env func(string) string) (*Config, error) { cfg := *c if c.UI.Covers != nil { v := *c.UI.Covers cfg.UI.Covers = &v } if c.UI.NotifyDownloads != nil { v := *c.UI.NotifyDownloads cfg.UI.NotifyDownloads = &v } cfg.applyEnv(env) cfg.applyDefaults() if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } // envVars maps the settings that can be overridden by the environment to the // variable that does it. var envVars = map[string]string{ "radarr.url": "ARR_TUI_RADARR_URL", "radarr.apikey": "ARR_TUI_RADARR_APIKEY", "sonarr.url": "ARR_TUI_SONARR_URL", "sonarr.apikey": "ARR_TUI_SONARR_APIKEY", "sabnzbd.url": "ARR_TUI_SABNZBD_URL", "sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY", } // Overrides returns, for every setting that the environment currently // overrides, the name of the variable. Keys look like "radarr.url". func Overrides(env func(string) string) map[string]string { out := map[string]string{} for key, name := range envVars { if env(name) != "" { out[key] = name } } return out } // Save writes the config to path as indented JSON. The file is replaced // atomically (a temporary file in the same directory, then a rename) and keeps // the permissions of the existing file, or is private (0600) when new: it // holds API keys. func Save(path string, cfg *Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') mode := os.FileMode(0o600) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() } tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp") if err != nil { return fmt.Errorf("saving config: %w", err) } defer os.Remove(tmp.Name()) // no-op after a successful rename if err := tmp.Chmod(mode); err != nil { tmp.Close() return fmt.Errorf("saving config: %w", err) } if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("saving config: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("saving config: %w", err) } if err := os.Rename(tmp.Name(), path); err != nil { return fmt.Errorf("saving config: %w", err) } return 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)) } } switch c.UI.CoverStyle { case "", "auto", "quadrant", "sextant", "kitty": default: errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant" or "quadrant"`, c.UI.CoverStyle)) } return errors.Join(errs...) }