Add the Config view: edit and save the settings, applied without a restart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+21
-2
@@ -11,16 +11,34 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
||||
type Client struct {
|
||||
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// settings returns the current base URL and API key.
|
||||
func (c *Client) settings() (baseURL, apiKey string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.baseURL, c.apiKey
|
||||
}
|
||||
|
||||
// Reconfigure points the client at another instance or key. Requests already
|
||||
// in flight are not affected.
|
||||
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.baseURL = strings.TrimRight(baseURL, "/")
|
||||
c.apiKey = apiKey
|
||||
}
|
||||
|
||||
// NewClient returns a client for the instance at baseURL (without /api/v3).
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
@@ -46,7 +64,8 @@ func (e *APIError) Error() string {
|
||||
// Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and
|
||||
// the response is decoded into out (if non-nil).
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
u := c.baseURL + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||
base, apiKey := c.settings()
|
||||
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
@@ -64,7 +83,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("X-Api-Key", apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -26,7 +26,8 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
}
|
||||
if inLibrary && img.URL != "" {
|
||||
local := img.URL
|
||||
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) {
|
||||
base, _ := c.settings()
|
||||
return base + "|" + local, func(ctx context.Context) ([]byte, error) {
|
||||
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
|
||||
if small := smallVariant(local); small != local {
|
||||
if data, err := c.GetBytes(ctx, small); err == nil {
|
||||
@@ -68,21 +69,23 @@ func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
_, apiKey := c.settings()
|
||||
req.Header.Set("X-Api-Key", apiKey)
|
||||
return doImage(c.http, req)
|
||||
}
|
||||
|
||||
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include
|
||||
// their URL base in image paths, so it must not be added twice.
|
||||
func (c *Client) resolve(ref string) (string, error) {
|
||||
base, err := url.Parse(c.baseURL)
|
||||
baseURL, _ := c.settings()
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
|
||||
return base.Scheme + "://" + base.Host + ref, nil
|
||||
}
|
||||
return c.baseURL + ref, nil
|
||||
return baseURL + ref, nil
|
||||
}
|
||||
|
||||
var imageClient = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
@@ -46,3 +46,39 @@ func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
|
||||
t.Error("want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureSwitchesInstanceAndKey(t *testing.T) {
|
||||
var gotKey, gotHost string
|
||||
mk := func(host string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey, gotHost = r.Header.Get("X-Api-Key"), host
|
||||
io.WriteString(w, `{"version":"1"}`)
|
||||
}))
|
||||
}
|
||||
a, b := mk("a"), mk("b")
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
c := NewClient(a.URL, "key-a")
|
||||
var out struct{ Version string }
|
||||
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "a" || gotKey != "key-a" {
|
||||
t.Fatalf("before: host=%q key=%q err=%v", gotHost, gotKey, err)
|
||||
}
|
||||
c.Reconfigure(b.URL+"/", "key-b") // a trailing slash is trimmed like in NewClient
|
||||
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "b" || gotKey != "key-b" {
|
||||
t.Fatalf("after: host=%q key=%q err=%v", gotHost, gotKey, err)
|
||||
}
|
||||
// Used from many goroutines while being changed (run with -race to see it matter).
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
c.Reconfigure(a.URL, "k")
|
||||
c.Reconfigure(b.URL, "k")
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
for i := 0; i < 50; i++ {
|
||||
_ = c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
+101
-1
@@ -103,14 +103,54 @@ func Load(path string) (*Config, []string, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
cfg.applyEnv(os.Getenv)
|
||||
// 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
|
||||
@@ -118,6 +158,66 @@ func Parse(data []byte) (*Config, error) {
|
||||
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 != "" {
|
||||
|
||||
@@ -109,3 +109,82 @@ func TestCoverStyleValidation(t *testing.T) {
|
||||
t.Error("unknown style accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRoundTripAndPermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
covers := false
|
||||
cfg := &Config{
|
||||
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
|
||||
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
|
||||
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
|
||||
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
|
||||
}
|
||||
if err := Save(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
|
||||
}
|
||||
got, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
|
||||
t.Errorf("round trip lost values: %+v", got)
|
||||
}
|
||||
// Unset optional settings stay out of the file.
|
||||
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
|
||||
t.Errorf("unset settings written:\n%s", data)
|
||||
}
|
||||
|
||||
// An existing file keeps its permissions and no temporary file is left behind.
|
||||
if err := os.Chmod(path, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Save(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("existing mode changed to %v", info.Mode().Perm())
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("leftover files in the config directory: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
|
||||
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
|
||||
|
||||
raw, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
|
||||
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
|
||||
}
|
||||
eff, err := raw.Effective(os.Getenv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
|
||||
t.Errorf("effective = %+v", eff)
|
||||
}
|
||||
if raw.Radarr.APIKey != "from-file" {
|
||||
t.Error("Effective must not modify the raw config")
|
||||
}
|
||||
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
|
||||
t.Errorf("overrides = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveValidates(t *testing.T) {
|
||||
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
|
||||
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
|
||||
t.Error("an invalid config must not become effective")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,26 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client talks to a SABnzbd instance.
|
||||
type Client struct {
|
||||
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Reconfigure points the client at another instance or key.
|
||||
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.baseURL = strings.TrimRight(baseURL, "/")
|
||||
c.apiKey = apiKey
|
||||
}
|
||||
|
||||
// NewClient returns a client for the instance at baseURL.
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
@@ -159,9 +169,12 @@ func boolParam(b bool) string {
|
||||
// {"status": false, "error": "..."}, which is turned into an error here.
|
||||
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||
params.Set("output", "json")
|
||||
params.Set("apikey", c.apiKey)
|
||||
c.mu.RLock()
|
||||
baseURL, apiKey := c.baseURL, c.apiKey
|
||||
c.mu.RUnlock()
|
||||
params.Set("apikey", apiKey)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api?"+params.Encode(), nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return stripURL(err)
|
||||
}
|
||||
|
||||
@@ -122,3 +122,22 @@ func TestTransportErrorHidesAPIKey(t *testing.T) {
|
||||
t.Errorf("error leaks API key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigure(t *testing.T) {
|
||||
var gotKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey = r.URL.Query().Get("apikey")
|
||||
w.Write([]byte(`{"version":"5.1"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("http://127.0.0.1:1", "old") // unreachable until reconfigured
|
||||
if _, err := c.Version(context.Background()); err == nil {
|
||||
t.Fatal("expected a connection error")
|
||||
}
|
||||
c.Reconfigure(srv.URL+"/", "new")
|
||||
v, err := c.Version(context.Background())
|
||||
if err != nil || v != "5.1" || gotKey != "new" {
|
||||
t.Errorf("version=%q key=%q err=%v", v, gotKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-22
@@ -25,6 +25,9 @@ type view struct {
|
||||
onShow, onHide func()
|
||||
// hints lists the keys that work in this view (shown in the status bar).
|
||||
hints func() string
|
||||
// captureKeys makes the global single-key shortcuts (q, ?) stand back
|
||||
// while this view is shown, because its fields take typed text.
|
||||
captureKeys bool
|
||||
}
|
||||
|
||||
// App is the top-level arr-tui application.
|
||||
@@ -46,8 +49,18 @@ type App struct {
|
||||
state map[string]serviceState // keyed by service name
|
||||
probes []probe
|
||||
|
||||
screen tcell.Screen // remembered at the first draw, for the bell
|
||||
screen tcell.Screen // remembered at the first draw, for the bell
|
||||
cfg *config.Config // the settings in use (file, environment and defaults combined)
|
||||
cfgPath string // the config file, empty if unknown
|
||||
cfgFile *config.Config // the file's contents as written, for saving
|
||||
radarr *radarr.Client
|
||||
sonarr *sonarr.Client
|
||||
sab *sabnzbd.Client
|
||||
downloads *downloadsView
|
||||
settings *settingsView
|
||||
panes []*detailPane // every detail pane, to restyle posters when settings change
|
||||
reconnect []func() // run after the service addresses or keys changed
|
||||
watchStop context.CancelFunc
|
||||
notifyDone bool // announce finished downloads
|
||||
bell bool
|
||||
covers bool // poster thumbnails enabled
|
||||
@@ -111,8 +124,10 @@ func New(cfg *config.Config) *App {
|
||||
a.gfx = newKittyGraphics(a.writeTerminal)
|
||||
}
|
||||
|
||||
a.cfg = cfg
|
||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||
a.radarr, a.sonarr = radarrClient, sonarrClient
|
||||
a.logs = newLogView(a)
|
||||
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
||||
a.sab = sabClient
|
||||
@@ -127,6 +142,8 @@ func New(cfg *config.Config) *App {
|
||||
}},
|
||||
{"SABnzbd", sabClient.Version},
|
||||
}
|
||||
settings := newSettingsView(a)
|
||||
a.settings = settings
|
||||
calendar := newCalendarView(a, radarrClient, sonarrClient)
|
||||
library := newLibraryView(a, radarrClient, sonarrClient)
|
||||
downloads := newDownloadsView(a, sabClient,
|
||||
@@ -135,13 +152,15 @@ func New(cfg *config.Config) *App {
|
||||
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
||||
},
|
||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||
a.downloads = downloads
|
||||
a.views = []view{
|
||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
|
||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
|
||||
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
||||
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
||||
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||||
{key: '6', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
||||
{key: '7', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints, captureKeys: true},
|
||||
}
|
||||
for i, v := range a.views {
|
||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
||||
@@ -236,6 +255,7 @@ func (a *App) setNarrow(narrow bool) {
|
||||
a.narrow = narrow
|
||||
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
|
||||
a.renderServices()
|
||||
a.renderTabs()
|
||||
}
|
||||
|
||||
func (a *App) renderServices() {
|
||||
@@ -295,27 +315,31 @@ func (a *App) Run() error {
|
||||
}
|
||||
}()
|
||||
go a.watchServices(stop)
|
||||
if a.notifyDone {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.watchDownloads(ctx, a.sab, a.bell)
|
||||
}
|
||||
a.setNotifyDownloads(a.notifyDone)
|
||||
defer a.setNotifyDownloads(false)
|
||||
return a.app.Run()
|
||||
}
|
||||
|
||||
// setNotifyDownloads starts or stops the background watch for finished downloads.
|
||||
func (a *App) setNotifyDownloads(on bool) {
|
||||
a.notifyDone = on
|
||||
if a.watchStop != nil {
|
||||
a.watchStop()
|
||||
a.watchStop = nil
|
||||
}
|
||||
if on {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.watchStop = cancel
|
||||
go a.watchDownloads(ctx, a.sab)
|
||||
}
|
||||
}
|
||||
|
||||
// watchServices checks all services now and then every heartbeat until stop is closed.
|
||||
func (a *App) watchServices(stop <-chan struct{}) {
|
||||
tick := time.NewTicker(heartbeat)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
for _, p := range a.probes {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
version, err := p.check(ctx)
|
||||
a.post(func() { a.SetService(p.name, version, err) })
|
||||
}()
|
||||
}
|
||||
a.probeAll()
|
||||
select {
|
||||
case <-tick.C:
|
||||
case <-stop:
|
||||
@@ -324,15 +348,34 @@ func (a *App) watchServices(stop <-chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// probeAll checks every service in the background and updates the indicators.
|
||||
func (a *App) probeAll() {
|
||||
for _, p := range a.probes {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
version, err := p.check(ctx)
|
||||
a.post(func() { a.SetService(p.name, version, err) })
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) renderStatus() {
|
||||
var hints string
|
||||
if n := len(a.overlays); n > 0 {
|
||||
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
||||
} else {
|
||||
if h := a.views[a.active].hints; h != nil {
|
||||
hints = h() + " · "
|
||||
v := a.views[a.active]
|
||||
var parts []string
|
||||
if v.hints != nil {
|
||||
parts = append(parts, v.hints())
|
||||
}
|
||||
hints += "? help · F1-F6 · q quit"
|
||||
if v.captureKeys { // q and ? are text there
|
||||
parts = append(parts, "F9 help · F1-F7 views")
|
||||
} else {
|
||||
parts = append(parts, "? help · F1-F7 · q quit")
|
||||
}
|
||||
hints = strings.Join(parts, " · ")
|
||||
}
|
||||
a.status.SetText("[gray]" + hints)
|
||||
}
|
||||
@@ -419,7 +462,7 @@ func (a *App) onlyFullOverlays() bool {
|
||||
// typing reports whether keystrokes belong to a text field or dialog and must
|
||||
// not trigger global shortcuts.
|
||||
func (a *App) typing() bool {
|
||||
if len(a.overlays) > 0 {
|
||||
if len(a.overlays) > 0 || a.views[a.active].captureKeys {
|
||||
return true
|
||||
}
|
||||
_, isInput := a.app.GetFocus().(*tview.InputField)
|
||||
@@ -483,9 +526,14 @@ func (a *App) switchTo(i int) {
|
||||
func (a *App) renderTabs() {
|
||||
var s string
|
||||
for i, v := range a.views {
|
||||
if i == a.active {
|
||||
switch {
|
||||
case a.narrow && i == a.active:
|
||||
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
|
||||
case a.narrow:
|
||||
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
|
||||
case i == a.active:
|
||||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||||
} else {
|
||||
default:
|
||||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func newTestApp(t *testing.T) *App {
|
||||
|
||||
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
for _, r := range "123456\t" {
|
||||
for _, r := range "1234567\t" {
|
||||
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
|
||||
if r == '\t' {
|
||||
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
|
||||
@@ -32,7 +32,7 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
||||
t.Fatalf("key %q switched to view %d", r, a.active)
|
||||
}
|
||||
}
|
||||
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6} {
|
||||
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6, tcell.KeyF7} {
|
||||
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
|
||||
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
|
||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
||||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||||
v.table.SetInputCapture(v.handleKey)
|
||||
app.reconnect = append(app.reconnect, func() { v.entries, v.loaded = nil, false })
|
||||
v.render()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
)
|
||||
|
||||
// SetConfigSource tells the app which file the settings came from and what that
|
||||
// file contains, so the Config view can save changes. raw is the file as
|
||||
// written (see config.LoadFile), not the effective settings.
|
||||
func (a *App) SetConfigSource(path string, raw *config.Config) {
|
||||
a.cfgPath, a.cfgFile = path, raw
|
||||
if a.settings != nil {
|
||||
a.settings.describe()
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfig puts new settings into effect without a restart.
|
||||
func (a *App) applyConfig(c *config.Config) {
|
||||
old := a.cfg
|
||||
a.cfg = c
|
||||
|
||||
if old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service || old.SABnzbd != c.SABnzbd {
|
||||
a.radarr.Reconfigure(c.Radarr.URL, c.Radarr.APIKey)
|
||||
a.sonarr.Reconfigure(c.Sonarr.URL, c.Sonarr.APIKey)
|
||||
a.sab.Reconfigure(c.SABnzbd.URL, c.SABnzbd.APIKey)
|
||||
a.probeAll() // the indicators, right away
|
||||
for _, f := range a.reconnect {
|
||||
f()
|
||||
}
|
||||
}
|
||||
if old.UI.RefreshSeconds != c.UI.RefreshSeconds {
|
||||
a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second)
|
||||
}
|
||||
a.bell = c.UI.Bell
|
||||
if c.UI.ShouldNotifyDownloads() != a.notifyDone {
|
||||
a.setNotifyDownloads(c.UI.ShouldNotifyDownloads())
|
||||
}
|
||||
if old.UI.ShowCovers() != c.UI.ShowCovers() || old.UI.CoverStyle != c.UI.CoverStyle {
|
||||
a.covers = c.UI.ShowCovers()
|
||||
a.restyleCovers()
|
||||
}
|
||||
}
|
||||
|
||||
// restyleCovers applies a changed poster style or switch to every detail pane.
|
||||
func (a *App) restyleCovers() {
|
||||
blocks, graphics := resolveCovers(a.cfg.UI.CoverStyle, os.Getenv)
|
||||
if a.gfx != nil {
|
||||
a.gfx.deleteAll() // the terminal's copies belong to the old images
|
||||
}
|
||||
a.coverStyle = blocks
|
||||
switch {
|
||||
case graphics && a.gfx == nil:
|
||||
a.gfx = newKittyGraphics(a.writeTerminal)
|
||||
case !graphics:
|
||||
a.gfx = nil
|
||||
}
|
||||
a.coverCache = newCoverCache() // stored copies depend on the style (their height)
|
||||
for _, p := range a.panes {
|
||||
p.cover.style, p.cover.gfx = blocks, a.gfx
|
||||
p.cover.cells = nil
|
||||
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -158,11 +159,11 @@ func TestCoverViewDraws(t *testing.T) {
|
||||
|
||||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
fetches := 0
|
||||
var fetches atomic.Int32
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, stripes(10, 20))
|
||||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
||||
fetches++
|
||||
fetches.Add(1)
|
||||
return buf.Bytes(), nil
|
||||
}}
|
||||
|
||||
@@ -173,8 +174,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||
if len(a.coverCache.waiting["k"]) != 2 {
|
||||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||||
}
|
||||
if fetches > 1 {
|
||||
t.Errorf("fetched %d times", fetches)
|
||||
if n := fetches.Load(); n > 1 {
|
||||
t.Errorf("fetched %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,9 @@ type detailPane struct {
|
||||
cover *coverView
|
||||
|
||||
detail detailText
|
||||
want string // key of the poster that should be shown
|
||||
hasCover bool // a poster (or its loading note) is part of the content
|
||||
lastRef coverRef // the poster of the current detail, to fetch it again after a style change
|
||||
want string // key of the poster that should be shown
|
||||
hasCover bool // a poster (or its loading note) is part of the content
|
||||
|
||||
// What the text was last wrapped for.
|
||||
laidOut struct {
|
||||
@@ -47,12 +48,13 @@ func newDetailPane(app *App) *detailPane {
|
||||
}
|
||||
d.AddItem(d.text, 0, 1, false)
|
||||
d.SetBorder(true).SetTitle(" Details ")
|
||||
app.panes = append(app.panes, d)
|
||||
return d
|
||||
}
|
||||
|
||||
// Show displays the text and, if there is one, the poster.
|
||||
func (d *detailPane) Show(text detailText, ref coverRef) {
|
||||
d.detail = text
|
||||
d.detail, d.lastRef = text, ref
|
||||
d.laidOut.valid = false
|
||||
if !d.app.covers || ref.fetch == nil {
|
||||
d.want, d.hasCover = "", false
|
||||
|
||||
@@ -134,6 +134,14 @@ func (v *downloadsView) nextPanel(dir int) {
|
||||
v.app.app.SetFocus(v.panels[0])
|
||||
}
|
||||
|
||||
// setInterval changes the polling interval, also while polling.
|
||||
func (v *downloadsView) setInterval(d time.Duration) {
|
||||
v.interval = d
|
||||
if v.cancel != nil {
|
||||
v.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// activate starts polling; called when the view becomes visible.
|
||||
func (v *downloadsView) activate() {
|
||||
v.deactivate()
|
||||
|
||||
@@ -51,9 +51,24 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
||||
}
|
||||
switch ev.Key() {
|
||||
case tcell.KeyDown:
|
||||
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
|
||||
return nil
|
||||
}
|
||||
f.SetFocus(min(pos+1, total-1))
|
||||
case tcell.KeyUp:
|
||||
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
|
||||
return nil
|
||||
}
|
||||
f.SetFocus(max(pos-1, 0))
|
||||
case tcell.KeyTab, tcell.KeyBacktab:
|
||||
dir := 1
|
||||
if ev.Key() == tcell.KeyBacktab {
|
||||
dir = -1
|
||||
}
|
||||
if hooks != nil && hooks.tab != nil && hooks.tab(dir) {
|
||||
return nil
|
||||
}
|
||||
return ev
|
||||
case tcell.KeyLeft, tcell.KeyRight:
|
||||
step := 1
|
||||
if ev.Key() == tcell.KeyLeft {
|
||||
@@ -118,6 +133,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
||||
type browseHooks struct {
|
||||
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
|
||||
abort func() // the list was closed without selecting
|
||||
// edge is called when Down is pressed on the last field or Up on the first;
|
||||
// it returns true if it moved the focus elsewhere.
|
||||
edge func(down bool) bool
|
||||
// tab is called for Tab (dir 1) and Shift+Tab (dir -1); true if handled.
|
||||
tab func(dir int) bool
|
||||
}
|
||||
|
||||
// browser mirrors the highlight of an open dropdown list, which tview does
|
||||
|
||||
+9
-1
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
const helpText = `[yellow]Global[-]
|
||||
F1-F6 switch view (also from the release and episode lists)
|
||||
F1-F7 switch view (also from the release and episode lists)
|
||||
Tab, Shift+Tab switch panel within a view
|
||||
?, F9 this help (F9 works while typing)
|
||||
q, Ctrl+C quit
|
||||
@@ -67,6 +67,14 @@ const helpText = `[yellow]Global[-]
|
||||
Popups fade away by themselves; everything they said is kept here.
|
||||
↑↓, PgUp/PgDn scroll G follow new entries c clear
|
||||
|
||||
[yellow]Config (F7)[-]
|
||||
Edit the settings of the config file: service addresses and API keys, add-form
|
||||
defaults, refresh interval, posters, notifications.
|
||||
↑↓ move, Tab next section, Space toggles, Enter presses a button
|
||||
Test connections checks the three services with the values typed in
|
||||
Save writes the file and applies everything at once; Revert discards edits
|
||||
Letters such as q and ? are typed here; F9 opens this help
|
||||
|
||||
[yellow]Connections[-]
|
||||
`
|
||||
|
||||
|
||||
@@ -263,6 +263,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
||||
v.render()
|
||||
})
|
||||
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
||||
app.reconnect = append(app.reconnect, func() {
|
||||
// Another server: forget what the old one said, reload when shown.
|
||||
v.seriesFiles = map[int]seriesFiles{}
|
||||
for _, p := range v.panes {
|
||||
p.all, p.shown, p.loaded, p.err = nil, nil, false, nil
|
||||
}
|
||||
v.render()
|
||||
})
|
||||
v.render()
|
||||
return v
|
||||
}
|
||||
|
||||
+20
-5
@@ -25,7 +25,6 @@ const (
|
||||
type searchView struct {
|
||||
app *App
|
||||
be backend
|
||||
cfg config.Arr
|
||||
|
||||
root *tview.Flex
|
||||
input *tview.InputField
|
||||
@@ -46,11 +45,10 @@ type searchView struct {
|
||||
loaded bool
|
||||
}
|
||||
|
||||
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
||||
func newSearchView(app *App, be backend) *searchView {
|
||||
v := &searchView{
|
||||
app: app,
|
||||
be: be,
|
||||
cfg: cfg,
|
||||
input: tview.NewInputField().SetLabel("Search: "),
|
||||
list: tview.NewList().ShowSecondaryText(false),
|
||||
details: newDetailPane(app),
|
||||
@@ -106,10 +104,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
||||
return ev
|
||||
})
|
||||
|
||||
app.reconnect = append(app.reconnect, v.reloadOptions)
|
||||
go v.loadOptions()
|
||||
return v
|
||||
}
|
||||
|
||||
// defaults returns the add-form defaults of the service, as currently configured.
|
||||
func (v *searchView) defaults() config.Arr {
|
||||
if v.be.name() == "Radarr" {
|
||||
return v.app.cfg.Radarr
|
||||
}
|
||||
return v.app.cfg.Sonarr
|
||||
}
|
||||
|
||||
// reloadOptions fetches the profiles and folders again, after the service changed.
|
||||
func (v *searchView) reloadOptions() {
|
||||
v.mu.Lock()
|
||||
v.loaded = false
|
||||
v.mu.Unlock()
|
||||
go v.loadOptions()
|
||||
}
|
||||
|
||||
// loadOptions checks the connection and fetches the data the add form needs.
|
||||
func (v *searchView) loadOptions() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
@@ -301,8 +316,8 @@ func (v *searchView) add(i int) {
|
||||
lSearch = "Search now"
|
||||
)
|
||||
form := tview.NewForm().
|
||||
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
|
||||
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
|
||||
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
|
||||
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
|
||||
height := 13
|
||||
if len(modes) > 0 {
|
||||
form.AddDropDown(lEpisode, modeNames, 0, nil)
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||
)
|
||||
|
||||
// settingsValues are the settings as shown in the Config view.
|
||||
type settingsValues struct {
|
||||
radarrURL, radarrKey, radarrProfile, radarrFolder string
|
||||
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
|
||||
sabURL, sabKey string
|
||||
refresh string // seconds, as typed
|
||||
covers bool
|
||||
coverStyle string
|
||||
notify, bell bool
|
||||
}
|
||||
|
||||
var coverStyles = []string{"auto", "kitty", "sextant", "quadrant"}
|
||||
|
||||
// settingsFrom reads the settings in use.
|
||||
func settingsFrom(c *config.Config) settingsValues {
|
||||
style := c.UI.CoverStyle
|
||||
if style == "" {
|
||||
style = "auto"
|
||||
}
|
||||
return settingsValues{
|
||||
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
|
||||
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
|
||||
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
|
||||
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
|
||||
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
|
||||
refresh: strconv.Itoa(c.UI.RefreshSeconds),
|
||||
covers: c.UI.ShowCovers(), coverStyle: style,
|
||||
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
|
||||
}
|
||||
}
|
||||
|
||||
// applySettings returns raw (the config file's contents) with the edited
|
||||
// values. Settings the environment overrides are left as the file has them, so
|
||||
// a value that only came from the environment is never written to disk. The
|
||||
// result is checked the way the program checks its configuration.
|
||||
func applySettings(raw config.Config, v settingsValues, overridden map[string]string) (config.Config, error) {
|
||||
set := func(key string, dst *string, value string) {
|
||||
if _, env := overridden[key]; !env {
|
||||
*dst = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
set("radarr.url", &raw.Radarr.URL, v.radarrURL)
|
||||
set("radarr.apikey", &raw.Radarr.APIKey, v.radarrKey)
|
||||
set("sonarr.url", &raw.Sonarr.URL, v.sonarrURL)
|
||||
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
|
||||
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
|
||||
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
|
||||
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
|
||||
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
|
||||
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
|
||||
raw.Sonarr.DefaultRootFolder = strings.TrimSpace(v.sonarrFolder)
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v.refresh))
|
||||
if err != nil || n < 1 || n > 3600 {
|
||||
return raw, fmt.Errorf("refresh interval: %q is not a number of seconds between 1 and 3600", v.refresh)
|
||||
}
|
||||
raw.UI.RefreshSeconds = n
|
||||
// Defaults stay out of the file unless the file already named them.
|
||||
raw.UI.Covers = boolSetting(raw.UI.Covers, v.covers, true)
|
||||
raw.UI.NotifyDownloads = boolSetting(raw.UI.NotifyDownloads, v.notify, true)
|
||||
raw.UI.Bell = v.bell
|
||||
raw.UI.CoverStyle = v.coverStyle
|
||||
if v.coverStyle == "auto" {
|
||||
raw.UI.CoverStyle = ""
|
||||
}
|
||||
|
||||
if _, err := raw.Effective(os.Getenv); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// boolSetting keeps an optional boolean out of the file while it equals its
|
||||
// default and was not in the file before.
|
||||
func boolSetting(old *bool, value, def bool) *bool {
|
||||
if old == nil && value == def {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// settingsView edits the configuration file's settings. Everything except
|
||||
// the cover style of already open panes applies right after saving.
|
||||
type settingsView struct {
|
||||
app *App
|
||||
root *tview.Flex
|
||||
|
||||
radarr, sonarr, sab, ui, actions *tview.Form
|
||||
groups []*tview.Form // keyboard order
|
||||
status *tview.TextView
|
||||
|
||||
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
||||
refresh *tview.InputField
|
||||
covers *tview.Checkbox
|
||||
style *tview.DropDown
|
||||
notify *tview.Checkbox
|
||||
bell *tview.Checkbox
|
||||
showKeys *tview.Checkbox
|
||||
overridden map[string]string
|
||||
dirty bool
|
||||
filling bool // set while the fields are filled, so that does not count as an edit
|
||||
}
|
||||
|
||||
func settingsHints() string {
|
||||
return "↑↓ move · Tab next section · Space toggles · Enter presses a button"
|
||||
}
|
||||
|
||||
func newSettingsView(app *App) *settingsView {
|
||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
||||
|
||||
section := func(title string) *tview.Form {
|
||||
f := tview.NewForm().SetItemPadding(0)
|
||||
f.SetBorder(true).SetTitle(" " + title + " ")
|
||||
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
||||
return f
|
||||
}
|
||||
input := func(f *tview.Form, label, key string, width int) {
|
||||
in := tview.NewInputField().SetLabel(label).SetFieldWidth(width)
|
||||
in.SetLabelWidth(24) // the same column in every section
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
in.SetMaskCharacter('*')
|
||||
}
|
||||
if _, ok := v.overridden[key]; ok {
|
||||
in.SetLabel(label + " (env)")
|
||||
in.SetDisabled(true)
|
||||
}
|
||||
in.SetChangedFunc(func(string) { v.edited() })
|
||||
f.AddFormItem(in)
|
||||
v.inputs[key] = in
|
||||
}
|
||||
service := func(f *tview.Form, name string, withDefaults bool) {
|
||||
input(f, "Address", name+".url", 0)
|
||||
input(f, "API key", name+".apikey", 0)
|
||||
if withDefaults {
|
||||
input(f, "Default quality profile", name+".profile", 0)
|
||||
input(f, "Default root folder", name+".folder", 0)
|
||||
}
|
||||
}
|
||||
v.radarr, v.sonarr, v.sab = section("Radarr"), section("Sonarr"), section("SABnzbd")
|
||||
service(v.radarr, "radarr", true)
|
||||
service(v.sonarr, "sonarr", true)
|
||||
service(v.sab, "sabnzbd", false)
|
||||
|
||||
v.ui = section("Interface")
|
||||
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
||||
v.ui.AddFormItem(v.refresh)
|
||||
v.ui.AddCheckbox("Posters", true, func(bool) { v.edited() })
|
||||
v.ui.AddDropDown("Poster style", coverStyles, 0, func(string, int) { v.edited() })
|
||||
v.ui.AddCheckbox("Notify about finished downloads", true, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Ring the terminal bell too", false, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Show API keys", false, func(shown bool) { v.maskKeys(!shown) })
|
||||
v.covers = v.ui.GetFormItem(1).(*tview.Checkbox)
|
||||
v.style = v.ui.GetFormItem(2).(*tview.DropDown)
|
||||
v.notify = v.ui.GetFormItem(3).(*tview.Checkbox)
|
||||
v.bell = v.ui.GetFormItem(4).(*tview.Checkbox)
|
||||
v.showKeys = v.ui.GetFormItem(5).(*tview.Checkbox)
|
||||
|
||||
v.actions = tview.NewForm().SetItemPadding(0)
|
||||
v.actions.SetBorder(true).SetTitle(" Actions ")
|
||||
v.actions.SetBorderPadding(0, 0, 1, 1)
|
||||
v.actions.AddButton("Test connections", v.test)
|
||||
v.actions.AddButton("Save", v.save)
|
||||
v.actions.AddButton("Revert", v.revert)
|
||||
|
||||
v.status = tview.NewTextView().SetDynamicColors(true).SetWordWrap(true)
|
||||
v.status.SetBorder(true).SetTitle(" Result ")
|
||||
|
||||
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.radarr, 6, 0, true).
|
||||
AddItem(v.sonarr, 6, 0, false).
|
||||
AddItem(v.sab, 4, 0, false)
|
||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.ui, 8, 0, false).
|
||||
AddItem(v.actions, 4, 0, false).
|
||||
AddItem(v.status, 0, 1, false)
|
||||
v.root = tview.NewFlex().AddItem(left, 0, 1, true).AddItem(right, 0, 1, false)
|
||||
|
||||
v.groups = []*tview.Form{v.radarr, v.sonarr, v.sab, v.ui, v.actions}
|
||||
for i, g := range v.groups {
|
||||
i := i
|
||||
arrowNavigation(g, func() {}, &browseHooks{
|
||||
edge: func(down bool) bool {
|
||||
if down && i+1 < len(v.groups) {
|
||||
v.focusGroup(i+1, false)
|
||||
return true
|
||||
}
|
||||
if !down && i > 0 {
|
||||
v.focusGroup(i-1, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
tab: func(dir int) bool {
|
||||
v.focusGroup((i+dir+len(v.groups))%len(v.groups), false)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
v.fill(settingsFrom(app.cfg))
|
||||
v.maskKeys(true)
|
||||
v.describe()
|
||||
return v
|
||||
}
|
||||
|
||||
// focusGroup moves the focus to a section, to its first or last field.
|
||||
func (v *settingsView) focusGroup(i int, last bool) {
|
||||
// tview only blurs the leaf that had the focus, not the other items of a
|
||||
// form or the form itself; a form that keeps thinking it has focus would
|
||||
// take the key presses.
|
||||
for j, other := range v.groups {
|
||||
if j != i {
|
||||
blurForm(other)
|
||||
}
|
||||
}
|
||||
g := v.groups[i]
|
||||
idx := 0
|
||||
if last {
|
||||
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
||||
}
|
||||
g.SetFocus(idx)
|
||||
v.app.app.SetFocus(g)
|
||||
}
|
||||
|
||||
// blurForm takes the focus away from a form and everything in it.
|
||||
func blurForm(f *tview.Form) {
|
||||
for i := 0; i < f.GetFormItemCount(); i++ {
|
||||
f.GetFormItem(i).Blur()
|
||||
}
|
||||
for i := 0; i < f.GetButtonCount(); i++ {
|
||||
f.GetButton(i).Blur()
|
||||
}
|
||||
f.Blur()
|
||||
}
|
||||
|
||||
func (v *settingsView) edited() {
|
||||
if !v.filling {
|
||||
v.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// maskKeys hides or shows the API keys.
|
||||
func (v *settingsView) maskKeys(hide bool) {
|
||||
for key, in := range v.inputs {
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
if hide {
|
||||
in.SetMaskCharacter('*')
|
||||
} else {
|
||||
in.SetMaskCharacter(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fill puts the values into the fields.
|
||||
func (v *settingsView) fill(s settingsValues) {
|
||||
v.filling = true
|
||||
defer func() { v.filling = false }()
|
||||
set := map[string]string{
|
||||
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
||||
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
||||
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
||||
}
|
||||
for key, value := range set {
|
||||
v.inputs[key].SetText(value)
|
||||
}
|
||||
v.refresh.SetText(s.refresh)
|
||||
v.covers.SetChecked(s.covers)
|
||||
for i, name := range coverStyles {
|
||||
if name == s.coverStyle {
|
||||
v.style.SetCurrentOption(i)
|
||||
}
|
||||
}
|
||||
v.notify.SetChecked(s.notify)
|
||||
v.bell.SetChecked(s.bell)
|
||||
v.dirty = false
|
||||
}
|
||||
|
||||
// read collects the values from the fields.
|
||||
func (v *settingsView) read() settingsValues {
|
||||
text := func(key string) string { return v.inputs[key].GetText() }
|
||||
i, _ := v.style.GetCurrentOption()
|
||||
style := "auto"
|
||||
if i >= 0 && i < len(coverStyles) {
|
||||
style = coverStyles[i]
|
||||
}
|
||||
return settingsValues{
|
||||
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
|
||||
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
|
||||
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
|
||||
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
|
||||
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
|
||||
refresh: v.refresh.GetText(),
|
||||
covers: v.covers.IsChecked(), coverStyle: style,
|
||||
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
|
||||
}
|
||||
}
|
||||
|
||||
// describe writes the standing information into the result box.
|
||||
func (v *settingsView) describe() {
|
||||
var b strings.Builder
|
||||
if v.app.cfgPath == "" {
|
||||
b.WriteString("[yellow]No config file is known, changes cannot be saved.[-]\n")
|
||||
} else {
|
||||
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
||||
}
|
||||
b.WriteString("Changes take effect when you save; nothing needs a restart.\n")
|
||||
if len(v.overridden) > 0 {
|
||||
var names []string
|
||||
for _, env := range v.overridden {
|
||||
names = append(names, env)
|
||||
}
|
||||
b.WriteString("[gray]Greyed-out fields come from the environment (" + strings.Join(names, ", ") + ") and are not written to the file.[-]\n")
|
||||
}
|
||||
v.status.SetText(b.String())
|
||||
}
|
||||
|
||||
// activate refreshes the fields when the view is shown, unless they hold unsaved edits.
|
||||
func (v *settingsView) activate() {
|
||||
if !v.dirty {
|
||||
v.fill(settingsFrom(v.app.cfg))
|
||||
}
|
||||
v.focusGroup(0, false)
|
||||
}
|
||||
|
||||
// report appends a line to the result box.
|
||||
func (v *settingsView) report(line string) {
|
||||
fmt.Fprintln(v.status, line)
|
||||
v.status.ScrollToEnd()
|
||||
}
|
||||
|
||||
func (v *settingsView) revert() {
|
||||
v.fill(settingsFrom(v.app.cfg))
|
||||
v.describe()
|
||||
v.report("[green]Reverted to the saved settings.[-]")
|
||||
}
|
||||
|
||||
// test checks the three services with the values in the fields, without saving.
|
||||
func (v *settingsView) test() {
|
||||
s := v.read()
|
||||
v.describe()
|
||||
v.report("Testing the connections…")
|
||||
type probeFn func(ctx context.Context) (string, error)
|
||||
tests := []struct {
|
||||
name string
|
||||
run probeFn
|
||||
}{
|
||||
{"Radarr", func(ctx context.Context) (string, error) {
|
||||
st, err := arr.NewClient(strings.TrimSpace(s.radarrURL), strings.TrimSpace(s.radarrKey)).SystemStatus(ctx)
|
||||
return st.Version, err
|
||||
}},
|
||||
{"Sonarr", func(ctx context.Context) (string, error) {
|
||||
st, err := arr.NewClient(strings.TrimSpace(s.sonarrURL), strings.TrimSpace(s.sonarrKey)).SystemStatus(ctx)
|
||||
return st.Version, err
|
||||
}},
|
||||
{"SABnzbd", func(ctx context.Context) (string, error) {
|
||||
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
|
||||
}},
|
||||
}
|
||||
for _, t := range tests {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
version, err := t.run(ctx)
|
||||
v.app.post(func() {
|
||||
if err != nil {
|
||||
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// save writes the config file and applies the settings.
|
||||
func (v *settingsView) save() {
|
||||
v.describe()
|
||||
if v.app.cfgPath == "" || v.app.cfgFile == nil {
|
||||
v.report("[red]There is no config file to save to.[-]")
|
||||
return
|
||||
}
|
||||
raw, err := applySettings(*v.app.cfgFile, v.read(), v.overridden)
|
||||
if err != nil {
|
||||
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
if err := config.Save(v.app.cfgPath, &raw); err != nil {
|
||||
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
effective, err := raw.Effective(os.Getenv)
|
||||
if err != nil { // cannot happen: applySettings validated it
|
||||
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.app.cfgFile = &raw
|
||||
v.app.applyConfig(effective)
|
||||
v.dirty = false
|
||||
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
||||
v.app.Notify(levelSuccess, "Settings saved")
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
)
|
||||
|
||||
func baseRaw() config.Config {
|
||||
return config.Config{
|
||||
Radarr: config.Arr{Service: config.Service{URL: "http://r:7878", APIKey: "rk"}},
|
||||
Sonarr: config.Arr{Service: config.Service{URL: "http://s:8989", APIKey: "sk"}},
|
||||
SABnzbd: config.Service{URL: "http://a:8080", APIKey: "ak"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettings(t *testing.T) {
|
||||
raw := baseRaw()
|
||||
v := settingsFrom(mustEffective(t, raw))
|
||||
if v.refresh != "3" || !v.covers || !v.notify || v.bell || v.coverStyle != "auto" {
|
||||
t.Fatalf("defaults shown wrong: %+v", v)
|
||||
}
|
||||
|
||||
// Unchanged settings add nothing but the refresh interval to the file.
|
||||
got, err := applySettings(raw, v, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.UI.Covers != nil || got.UI.NotifyDownloads != nil || got.UI.CoverStyle != "" || got.UI.Bell {
|
||||
t.Errorf("defaults written to the file: %+v", got.UI)
|
||||
}
|
||||
|
||||
// Edits are applied and whitespace is trimmed.
|
||||
v.radarrURL = " http://new:7878 "
|
||||
v.radarrProfile = " HD-1080p "
|
||||
v.sabKey = "newkey"
|
||||
v.refresh = "10"
|
||||
v.covers = false
|
||||
v.notify = false
|
||||
v.bell = true
|
||||
v.coverStyle = "quadrant"
|
||||
got, err = applySettings(raw, v, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
|
||||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" {
|
||||
t.Errorf("edits lost: %+v", got)
|
||||
}
|
||||
|
||||
// A setting that was explicit in the file stays explicit, even at its default.
|
||||
yes := true
|
||||
raw.UI.Covers = &yes
|
||||
v.covers = true
|
||||
got, _ = applySettings(raw, v, nil)
|
||||
if got.UI.Covers == nil || !*got.UI.Covers {
|
||||
t.Error("an explicit setting was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettingsValidates(t *testing.T) {
|
||||
v := settingsFrom(mustEffective(t, baseRaw()))
|
||||
for name, mutate := range map[string]func(*settingsValues){
|
||||
"refresh not a number": func(v *settingsValues) { v.refresh = "often" },
|
||||
"refresh zero": func(v *settingsValues) { v.refresh = "0" },
|
||||
"refresh too large": func(v *settingsValues) { v.refresh = "99999" },
|
||||
"address not http": func(v *settingsValues) { v.sonarrURL = "ftp://x" },
|
||||
"empty api key": func(v *settingsValues) { v.radarrKey = " " },
|
||||
"empty address": func(v *settingsValues) { v.sabURL = "" },
|
||||
} {
|
||||
w := v
|
||||
mutate(&w)
|
||||
if _, err := applySettings(baseRaw(), w, nil); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettingsKeepsEnvironmentValuesOutOfTheFile(t *testing.T) {
|
||||
raw := baseRaw()
|
||||
// The fields show the key from the environment...
|
||||
shown := settingsFrom(mustEffective(t, raw))
|
||||
shown.radarrKey = "secret-from-env"
|
||||
shown.radarrURL = "http://typed-over-env"
|
||||
got, err := applySettings(raw, shown, map[string]string{"radarr.apikey": "ARR_TUI_RADARR_APIKEY", "radarr.url": "ARR_TUI_RADARR_URL"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ...but the file keeps what it had.
|
||||
if got.Radarr.APIKey != "rk" || got.Radarr.URL != "http://r:7878" {
|
||||
t.Errorf("environment values leaked into the file: %+v", got.Radarr)
|
||||
}
|
||||
}
|
||||
|
||||
func mustEffective(t *testing.T, raw config.Config) *config.Config {
|
||||
t.Helper()
|
||||
eff, err := raw.Effective(func(string) string { return "" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
|
||||
t.Setenv("ARR_TUI_RADARR_APIKEY", "") // make sure the environment does not interfere
|
||||
var gotKey atomic.Value // the app also probes the server in the background
|
||||
radarr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey.Store(r.Header.Get("X-Api-Key"))
|
||||
w.Write([]byte(`{"version":"6.0"}`))
|
||||
}))
|
||||
defer radarr.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
raw := baseRaw()
|
||||
if err := config.Save(path, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, _ := config.LoadFile(path)
|
||||
a.SetConfigSource(path, loaded)
|
||||
v := a.settings
|
||||
v.fill(settingsFrom(a.cfg))
|
||||
|
||||
// Edit the fields like a user would and save.
|
||||
v.inputs["radarr.url"].SetText(radarr.URL)
|
||||
v.inputs["radarr.apikey"].SetText("brand-new-key")
|
||||
v.inputs["sonarr.profile"].SetText("Any")
|
||||
v.refresh.SetText("7")
|
||||
v.bell.SetChecked(true)
|
||||
if !v.dirty {
|
||||
t.Error("editing must mark the form as changed")
|
||||
}
|
||||
v.save()
|
||||
|
||||
// 1. The file has the new values and stays private.
|
||||
saved, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Radarr.URL != radarr.URL || saved.Radarr.APIKey != "brand-new-key" || saved.Sonarr.DefaultQualityProfile != "Any" || saved.UI.RefreshSeconds != 7 || !saved.UI.Bell {
|
||||
t.Errorf("file = %+v", saved)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode %v", info.Mode().Perm())
|
||||
}
|
||||
// 2. The running app uses them at once.
|
||||
if a.cfg.Radarr.URL != radarr.URL || !a.bell || a.cfg.UI.RefreshSeconds != 7 {
|
||||
t.Errorf("settings not applied: %+v", a.cfg)
|
||||
}
|
||||
if _, err := a.radarr.SystemStatus(t.Context()); err != nil || gotKey.Load() != "brand-new-key" {
|
||||
t.Errorf("the client still talks to the old instance: key=%v err=%v", gotKey.Load(), err)
|
||||
}
|
||||
if got := a.downloads.interval.Seconds(); got != 7 {
|
||||
t.Errorf("downloads interval = %v", got)
|
||||
}
|
||||
if v.dirty {
|
||||
t.Error("saving must clear the changed mark")
|
||||
}
|
||||
if !strings.Contains(v.status.GetText(false), "Saved") {
|
||||
t.Errorf("result box: %q", v.status.GetText(false))
|
||||
}
|
||||
|
||||
// An invalid edit is refused and leaves the file alone.
|
||||
before, _ := os.ReadFile(path)
|
||||
v.refresh.SetText("soon")
|
||||
v.save()
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(before) != string(after) || !strings.Contains(v.status.GetText(false), "Not saved") {
|
||||
t.Errorf("invalid settings were saved: %q", v.status.GetText(false))
|
||||
}
|
||||
// Revert goes back to what is in use.
|
||||
v.revert()
|
||||
if v.refresh.GetText() != "7" || v.dirty {
|
||||
t.Errorf("revert: refresh=%q dirty=%v", v.refresh.GetText(), v.dirty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsViewWithoutConfigFile(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.settings.save()
|
||||
if !strings.Contains(a.settings.status.GetText(false), "no config file") {
|
||||
t.Errorf("result box: %q", a.settings.status.GetText(false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsViewFocusMovesBetweenSections(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
v := a.settings
|
||||
var focus func(p tview.Primitive)
|
||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||
|
||||
v.focusGroup(3, false) // Interface
|
||||
for _, g := range v.groups {
|
||||
g.Focus(focus)
|
||||
}
|
||||
// Whatever flags were left behind, moving to the actions must leave exactly one section focused.
|
||||
v.focusGroup(4, false)
|
||||
focused := 0
|
||||
for _, g := range v.groups {
|
||||
if g.HasFocus() {
|
||||
focused++
|
||||
}
|
||||
}
|
||||
if focused != 1 || !v.actions.HasFocus() {
|
||||
t.Errorf("%d sections claim the focus, want only the actions", focused)
|
||||
}
|
||||
|
||||
// Down on the last field of a section continues in the next one; Tab cycles.
|
||||
v.focusGroup(0, false)
|
||||
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||
g := v.radarr
|
||||
g.SetFocus(g.GetFormItemCount() - 1)
|
||||
send(g, tcell.KeyDown)
|
||||
if !v.sonarr.HasFocus() || v.radarr.HasFocus() {
|
||||
t.Error("Down on the last field must move to the next section")
|
||||
}
|
||||
send(v.sonarr, tcell.KeyBacktab)
|
||||
if !v.radarr.HasFocus() || v.sonarr.HasFocus() {
|
||||
t.Error("Shift+Tab must move back a section")
|
||||
}
|
||||
send(v.radarr, tcell.KeyUp) // first section, first field: nothing before it
|
||||
if !v.radarr.HasFocus() {
|
||||
t.Error("Up on the first field of the first section must stay put")
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func diffHistory(seen map[string]string, slots []sabnzbd.HistorySlot, baseline b
|
||||
// watchDownloads polls SABnzbd's history until ctx ends and reports finished
|
||||
// and failed downloads as popups (and a bell, if enabled). Errors are not
|
||||
// reported here: the connection indicator and the log already do that.
|
||||
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client, bell bool) {
|
||||
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client) {
|
||||
seen := map[string]string{}
|
||||
baseline := true
|
||||
t := time.NewTicker(watchInterval)
|
||||
@@ -54,7 +54,7 @@ func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client, bell bool
|
||||
events := diffHistory(seen, slots, baseline)
|
||||
baseline = false
|
||||
if len(events) > 0 {
|
||||
a.post(func() { a.announce(events, bell) })
|
||||
a.post(func() { a.announce(events, a.bell) })
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user