From a4ec9ad71e7b171d5486543d3e336930a843beda Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 23:31:13 +0200 Subject: [PATCH] Add the Config view: edit and save the settings, applied without a restart Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + README.md | 7 +- SPECS.md | 9 +- cmd/arr-tui/main.go | 8 +- internal/arr/client.go | 23 +- internal/arr/image.go | 11 +- internal/arr/update_test.go | 36 +++ internal/config/config.go | 102 +++++++- internal/config/config_test.go | 79 ++++++ internal/sabnzbd/sabnzbd.go | 17 +- internal/sabnzbd/sabnzbd_test.go | 19 ++ internal/ui/app.go | 92 +++++-- internal/ui/app_test.go | 4 +- internal/ui/calendar.go | 1 + internal/ui/configapply.go | 66 +++++ internal/ui/cover_test.go | 9 +- internal/ui/detail.go | 8 +- internal/ui/downloads.go | 8 + internal/ui/form.go | 20 ++ internal/ui/help.go | 10 +- internal/ui/library.go | 8 + internal/ui/search.go | 25 +- internal/ui/settings.go | 419 +++++++++++++++++++++++++++++++ internal/ui/settings_test.go | 233 +++++++++++++++++ internal/ui/watch.go | 4 +- 25 files changed, 1168 insertions(+), 54 deletions(-) create mode 100644 internal/ui/configapply.go create mode 100644 internal/ui/settings.go create mode 100644 internal/ui/settings_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f7914a9..b3adc1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Config (F7):** edit the settings of the config file inside the program (addresses, API keys, + add-form defaults, refresh interval, posters, notifications), test the connections, save, and + have everything take effect immediately. Environment-provided values are never written to the file. + The Log is now on F6. - **Posters as real images** in kitty and Ghostty (Kitty graphics protocol with Unicode placeholders), at the terminal's full resolution and with the right aspect ratio. Other terminals keep the block-character posters, using sextants when fontconfig finds a font with the glyphs diff --git a/README.md b/README.md index 52cc7b5..4a8e7d4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video for releases or start an automatic search from there. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, retry failed downloads, remove from the arr queue and blocklist. +- **Config (F7)**: edit everything in the config file from inside the program: service addresses + and API keys, the add-form defaults, refresh interval, posters, notifications. *Test connections* + checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a + new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*` + environment variables are greyed out and are never written to the file. - **Log (F6)**: actions report through popups that fade away; the log keeps the history. - **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails, whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished @@ -67,7 +72,7 @@ Keep the file private (`chmod 600`); it holds your API keys. | Key | Action | |---|---| -| `F1`–`F6` | switch view (Movies, Series, Library, Downloads, Calendar, Log) | +| `F1`–`F7` | switch view (Movies, Series, Library, Downloads, Calendar, Log, Config) | | `Tab`, `Shift+Tab` | switch panel within a view | | `?` / `F9` | help | | `/` | focus search box | diff --git a/SPECS.md b/SPECS.md index a3f5fdc..d2e556d 100644 --- a/SPECS.md +++ b/SPECS.md @@ -69,7 +69,8 @@ Notes: Posters are JPEG/PNG, decoded and downscaled in the process. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. - **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. - **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that. -- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. +- **Config view (F7):** a form for every setting of the config file, in four sections (Radarr, Sonarr, SABnzbd, Interface) plus Test connections / Save / Revert. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test connections` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. `q` and `?` are typed text in this view (`F9` opens the help). +- **Notifications and log (F6):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. ### 4.2 v0.2 (done, M8) @@ -100,7 +101,7 @@ Layout sketch (Library view; the other views follow the same frame): └────────────────────────────────────────────────────────────────────┘ ``` -Popups for action feedback appear in the bottom right and fade away; the Log view (F5) keeps the history. +Popups for action feedback appear in the bottom right and fade away; the Log view (F6) keeps the history. tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive. @@ -108,7 +109,7 @@ Keybindings (the status bar shows the ones that apply to the current view): | Key | Action | |---|---| -| `F1-F6` | Switch view (Movies, Series, Library, Downloads, Calendar, Log); also while typing, and from full-screen views. Dialogs block them. | +| `F1-F7` | Switch view (Movies, Series, Library, Downloads, Calendar, Log, Config); also while typing, and from full-screen views. Dialogs block them. | | `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists | | `?` / `F9` | Help (`F9` also works while typing) | | `q` / `Ctrl+C` | Quit | @@ -204,7 +205,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). -- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. +- [x] **M9** — Popup notifications, Log view (F6), context-sensitive key hints in the status bar. - [x] **M10** — Download completion notifications, remove from library, calendar view (F5; the Log moves to F6). Multiple instances per service are out of scope. - [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. diff --git a/cmd/arr-tui/main.go b/cmd/arr-tui/main.go index ca0cda0..6118093 100644 --- a/cmd/arr-tui/main.go +++ b/cmd/arr-tui/main.go @@ -46,5 +46,11 @@ func run() error { fmt.Fprintln(os.Stderr, "warning:", w) } - return ui.New(cfg).Run() + raw, err := config.LoadFile(*path) + if err != nil { + return err + } + app := ui.New(cfg) + app.SetConfigSource(*path, raw) + return app.Run() } diff --git a/internal/arr/client.go b/internal/arr/client.go index d36708e..b4accb2 100644 --- a/internal/arr/client.go +++ b/internal/arr/client.go @@ -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/. 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") diff --git a/internal/arr/image.go b/internal/arr/image.go index 3a0ecbc..901bab2 100644 --- a/internal/arr/image.go +++ b/internal/arr/image.go @@ -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} diff --git a/internal/arr/update_test.go b/internal/arr/update_test.go index dddd7f2..98cd1ed 100644 --- a/internal/arr/update_test.go +++ b/internal/arr/update_test.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 2b02ffa..7e53c9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 243f49f..ec660e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/sabnzbd/sabnzbd.go b/internal/sabnzbd/sabnzbd.go index 451c0b1..ad78c1a 100644 --- a/internal/sabnzbd/sabnzbd.go +++ b/internal/sabnzbd/sabnzbd.go @@ -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) } diff --git a/internal/sabnzbd/sabnzbd_test.go b/internal/sabnzbd/sabnzbd_test.go index d868ec4..801d8d2 100644 --- a/internal/sabnzbd/sabnzbd_test.go +++ b/internal/sabnzbd/sabnzbd_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 629b2ef..78f7904 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index f051e18..44c1050 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -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) } diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 0ec6d82..c8e0cd9 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -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 } diff --git a/internal/ui/configapply.go b/internal/ui/configapply.go new file mode 100644 index 0000000..6b2eb69 --- /dev/null +++ b/internal/ui/configapply.go @@ -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 + } +} diff --git a/internal/ui/cover_test.go b/internal/ui/cover_test.go index 70c00e9..5d72d93 100644 --- a/internal/ui/cover_test.go +++ b/internal/ui/cover_test.go @@ -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) } } diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 5bcf414..9612b13 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -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 diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go index 0b58468..2fead73 100644 --- a/internal/ui/downloads.go +++ b/internal/ui/downloads.go @@ -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() diff --git a/internal/ui/form.go b/internal/ui/form.go index b30b8fd..efba6c8 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -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 diff --git a/internal/ui/help.go b/internal/ui/help.go index 119b3a7..b7c023f 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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[-] ` diff --git a/internal/ui/library.go b/internal/ui/library.go index 1fb9a43..4c4b0f5 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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 } diff --git a/internal/ui/search.go b/internal/ui/search.go index 17e0c28..5169203 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -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) diff --git a/internal/ui/settings.go b/internal/ui/settings.go new file mode 100644 index 0000000..1da084c --- /dev/null +++ b/internal/ui/settings.go @@ -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") +} diff --git a/internal/ui/settings_test.go b/internal/ui/settings_test.go new file mode 100644 index 0000000..c54f061 --- /dev/null +++ b/internal/ui/settings_test.go @@ -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") + } +} diff --git a/internal/ui/watch.go b/internal/ui/watch.go index 43e079c..8eebf37 100644 --- a/internal/ui/watch.go +++ b/internal/ui/watch.go @@ -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 {