// 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, 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) { 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)) } } 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...) }