273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
// 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...)
|
|
}
|