Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
283 lines
9.0 KiB
Go
283 lines
9.0 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), "cells" (1
|
|
// pixel per cell, background colors only, for terminals that draw block
|
|
// glyphs unevenly such as macOS Terminal), "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 cells in macOS Terminal, 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"`
|
|
// Theme selects the colors: "dark", "light" or "auto" (default: light when
|
|
// the terminal reports a light background, else dark).
|
|
Theme string `json:"theme,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", "cells", "kitty":
|
|
default:
|
|
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant", "quadrant" or "cells"`, c.UI.CoverStyle))
|
|
}
|
|
switch c.UI.Theme {
|
|
case "", "auto", "dark", "light":
|
|
default:
|
|
errs = append(errs, fmt.Errorf(`ui.theme: %q must be "auto", "dark" or "light"`, c.UI.Theme))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|