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:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- **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
|
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
|
keep the block-character posters, using sextants when fontconfig finds a font with the glyphs
|
||||||
|
|||||||
@@ -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.
|
for releases or start an automatic search from there.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||||
retry failed downloads, remove from the arr queue and blocklist.
|
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.
|
- **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,
|
- **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
|
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 |
|
| 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 |
|
| `Tab`, `Shift+Tab` | switch panel within a view |
|
||||||
| `?` / `F9` | help |
|
| `?` / `F9` | help |
|
||||||
| `/` | focus search box |
|
| `/` | focus search box |
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ Notes:
|
|||||||
Posters are JPEG/PNG, decoded and downscaled in the process. Library items fetch `/MediaCover/<id>/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.
|
Posters are JPEG/PNG, decoded and downscaled in the process. Library items fetch `/MediaCover/<id>/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.
|
- **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.
|
- **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)
|
### 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.
|
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 |
|
| 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 |
|
| `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) |
|
| `?` / `F9` | Help (`F9` also works while typing) |
|
||||||
| `q` / `Ctrl+C` | Quit |
|
| `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] **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] **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] **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.
|
- [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.
|
- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title.
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -46,5 +46,11 @@ func run() error {
|
|||||||
fmt.Fprintln(os.Stderr, "warning:", w)
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-2
@@ -11,16 +11,34 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
http *http.Client
|
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).
|
// NewClient returns a client for the instance at baseURL (without /api/v3).
|
||||||
func NewClient(baseURL, apiKey string) *Client {
|
func NewClient(baseURL, apiKey string) *Client {
|
||||||
return &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
|
// 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).
|
// 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 {
|
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 {
|
if len(query) > 0 {
|
||||||
u += "?" + query.Encode()
|
u += "?" + query.Encode()
|
||||||
}
|
}
|
||||||
@@ -64,7 +83,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Api-Key", c.apiKey)
|
req.Header.Set("X-Api-Key", apiKey)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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 != "" {
|
if inLibrary && img.URL != "" {
|
||||||
local := 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.
|
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
|
||||||
if small := smallVariant(local); small != local {
|
if small := smallVariant(local); small != local {
|
||||||
if data, err := c.GetBytes(ctx, small); err == nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
return doImage(c.http, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include
|
// 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.
|
// their URL base in image paths, so it must not be added twice.
|
||||||
func (c *Client) resolve(ref string) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
|
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
|
||||||
return base.Scheme + "://" + base.Host + ref, nil
|
return base.Scheme + "://" + base.Host + ref, nil
|
||||||
}
|
}
|
||||||
return c.baseURL + ref, nil
|
return baseURL + ref, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageClient = &http.Client{Timeout: 20 * time.Second}
|
var imageClient = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
|||||||
@@ -46,3 +46,39 @@ func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
|
|||||||
t.Error("want error")
|
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
|
// Parse decodes config JSON, applies environment overrides and defaults, and
|
||||||
// validates the result. Unknown fields are rejected to catch typos.
|
// validates the result. Unknown fields are rejected to catch typos.
|
||||||
func Parse(data []byte) (*Config, error) {
|
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
|
var cfg Config
|
||||||
dec := json.NewDecoder(bytes.NewReader(data))
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&cfg); err != nil {
|
if err := dec.Decode(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
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()
|
cfg.applyDefaults()
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -118,6 +158,66 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
return &cfg, nil
|
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) {
|
func (c *Config) applyEnv(get func(string) string) {
|
||||||
override := func(dst *string, key string) {
|
override := func(dst *string, key string) {
|
||||||
if v := get(key); v != "" {
|
if v := get(key); v != "" {
|
||||||
|
|||||||
@@ -109,3 +109,82 @@ func TestCoverStyleValidation(t *testing.T) {
|
|||||||
t.Error("unknown style accepted")
|
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"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client talks to a SABnzbd instance.
|
// Client talks to a SABnzbd instance.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
http *http.Client
|
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.
|
// NewClient returns a client for the instance at baseURL.
|
||||||
func NewClient(baseURL, apiKey string) *Client {
|
func NewClient(baseURL, apiKey string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
@@ -159,9 +169,12 @@ func boolParam(b bool) string {
|
|||||||
// {"status": false, "error": "..."}, which is turned into an error here.
|
// {"status": false, "error": "..."}, which is turned into an error here.
|
||||||
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||||
params.Set("output", "json")
|
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 {
|
if err != nil {
|
||||||
return stripURL(err)
|
return stripURL(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,22 @@ func TestTransportErrorHidesAPIKey(t *testing.T) {
|
|||||||
t.Errorf("error leaks API key: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+69
-21
@@ -25,6 +25,9 @@ type view struct {
|
|||||||
onShow, onHide func()
|
onShow, onHide func()
|
||||||
// hints lists the keys that work in this view (shown in the status bar).
|
// hints lists the keys that work in this view (shown in the status bar).
|
||||||
hints func() string
|
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.
|
// App is the top-level arr-tui application.
|
||||||
@@ -47,7 +50,17 @@ type App struct {
|
|||||||
probes []probe
|
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
|
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
|
notifyDone bool // announce finished downloads
|
||||||
bell bool
|
bell bool
|
||||||
covers bool // poster thumbnails enabled
|
covers bool // poster thumbnails enabled
|
||||||
@@ -111,8 +124,10 @@ func New(cfg *config.Config) *App {
|
|||||||
a.gfx = newKittyGraphics(a.writeTerminal)
|
a.gfx = newKittyGraphics(a.writeTerminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.cfg = cfg
|
||||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||||
|
a.radarr, a.sonarr = radarrClient, sonarrClient
|
||||||
a.logs = newLogView(a)
|
a.logs = newLogView(a)
|
||||||
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
||||||
a.sab = sabClient
|
a.sab = sabClient
|
||||||
@@ -127,6 +142,8 @@ func New(cfg *config.Config) *App {
|
|||||||
}},
|
}},
|
||||||
{"SABnzbd", sabClient.Version},
|
{"SABnzbd", sabClient.Version},
|
||||||
}
|
}
|
||||||
|
settings := newSettingsView(a)
|
||||||
|
a.settings = settings
|
||||||
calendar := newCalendarView(a, radarrClient, sonarrClient)
|
calendar := newCalendarView(a, radarrClient, sonarrClient)
|
||||||
library := newLibraryView(a, radarrClient, sonarrClient)
|
library := newLibraryView(a, radarrClient, sonarrClient)
|
||||||
downloads := newDownloadsView(a, sabClient,
|
downloads := newDownloadsView(a, sabClient,
|
||||||
@@ -135,13 +152,15 @@ func New(cfg *config.Config) *App {
|
|||||||
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
||||||
},
|
},
|
||||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||||
|
a.downloads = downloads
|
||||||
a.views = []view{
|
a.views = []view{
|
||||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
|
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
|
||||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).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: '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: '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: '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: '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 {
|
for i, v := range a.views {
|
||||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
a.pages.AddPage(v.name, v.body, true, i == 0)
|
||||||
@@ -236,6 +255,7 @@ func (a *App) setNarrow(narrow bool) {
|
|||||||
a.narrow = narrow
|
a.narrow = narrow
|
||||||
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
|
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
|
||||||
a.renderServices()
|
a.renderServices()
|
||||||
|
a.renderTabs()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderServices() {
|
func (a *App) renderServices() {
|
||||||
@@ -295,27 +315,31 @@ func (a *App) Run() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go a.watchServices(stop)
|
go a.watchServices(stop)
|
||||||
if a.notifyDone {
|
a.setNotifyDownloads(a.notifyDone)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
defer a.setNotifyDownloads(false)
|
||||||
defer cancel()
|
|
||||||
go a.watchDownloads(ctx, a.sab, a.bell)
|
|
||||||
}
|
|
||||||
return a.app.Run()
|
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.
|
// watchServices checks all services now and then every heartbeat until stop is closed.
|
||||||
func (a *App) watchServices(stop <-chan struct{}) {
|
func (a *App) watchServices(stop <-chan struct{}) {
|
||||||
tick := time.NewTicker(heartbeat)
|
tick := time.NewTicker(heartbeat)
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
for {
|
for {
|
||||||
for _, p := range a.probes {
|
a.probeAll()
|
||||||
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) })
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
case <-stop:
|
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() {
|
func (a *App) renderStatus() {
|
||||||
var hints string
|
var hints string
|
||||||
if n := len(a.overlays); n > 0 {
|
if n := len(a.overlays); n > 0 {
|
||||||
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
||||||
} else {
|
} else {
|
||||||
if h := a.views[a.active].hints; h != nil {
|
v := a.views[a.active]
|
||||||
hints = h() + " · "
|
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)
|
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
|
// typing reports whether keystrokes belong to a text field or dialog and must
|
||||||
// not trigger global shortcuts.
|
// not trigger global shortcuts.
|
||||||
func (a *App) typing() bool {
|
func (a *App) typing() bool {
|
||||||
if len(a.overlays) > 0 {
|
if len(a.overlays) > 0 || a.views[a.active].captureKeys {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
_, isInput := a.app.GetFocus().(*tview.InputField)
|
_, isInput := a.app.GetFocus().(*tview.InputField)
|
||||||
@@ -483,9 +526,14 @@ func (a *App) switchTo(i int) {
|
|||||||
func (a *App) renderTabs() {
|
func (a *App) renderTabs() {
|
||||||
var s string
|
var s string
|
||||||
for i, v := range a.views {
|
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)
|
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
|
||||||
} else {
|
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)
|
||||||
|
default:
|
||||||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
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) {
|
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
for _, r := range "123456\t" {
|
for _, r := range "1234567\t" {
|
||||||
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
|
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
|
||||||
if r == '\t' {
|
if r == '\t' {
|
||||||
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
|
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)
|
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 {
|
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)
|
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.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||||||
v.table.SetInputCapture(v.handleKey)
|
v.table.SetInputCapture(v.handleKey)
|
||||||
|
app.reconnect = append(app.reconnect, func() { v.entries, v.loaded = nil, false })
|
||||||
v.render()
|
v.render()
|
||||||
return v
|
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/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -158,11 +159,11 @@ func TestCoverViewDraws(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
fetches := 0
|
var fetches atomic.Int32
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
png.Encode(&buf, stripes(10, 20))
|
png.Encode(&buf, stripes(10, 20))
|
||||||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
||||||
fetches++
|
fetches.Add(1)
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -173,8 +174,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
|||||||
if len(a.coverCache.waiting["k"]) != 2 {
|
if len(a.coverCache.waiting["k"]) != 2 {
|
||||||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||||||
}
|
}
|
||||||
if fetches > 1 {
|
if n := fetches.Load(); n > 1 {
|
||||||
t.Errorf("fetched %d times", fetches)
|
t.Errorf("fetched %d times", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type detailPane struct {
|
|||||||
cover *coverView
|
cover *coverView
|
||||||
|
|
||||||
detail detailText
|
detail detailText
|
||||||
|
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
|
want string // key of the poster that should be shown
|
||||||
hasCover bool // a poster (or its loading note) is part of the content
|
hasCover bool // a poster (or its loading note) is part of the content
|
||||||
|
|
||||||
@@ -47,12 +48,13 @@ func newDetailPane(app *App) *detailPane {
|
|||||||
}
|
}
|
||||||
d.AddItem(d.text, 0, 1, false)
|
d.AddItem(d.text, 0, 1, false)
|
||||||
d.SetBorder(true).SetTitle(" Details ")
|
d.SetBorder(true).SetTitle(" Details ")
|
||||||
|
app.panes = append(app.panes, d)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show displays the text and, if there is one, the poster.
|
// Show displays the text and, if there is one, the poster.
|
||||||
func (d *detailPane) Show(text detailText, ref coverRef) {
|
func (d *detailPane) Show(text detailText, ref coverRef) {
|
||||||
d.detail = text
|
d.detail, d.lastRef = text, ref
|
||||||
d.laidOut.valid = false
|
d.laidOut.valid = false
|
||||||
if !d.app.covers || ref.fetch == nil {
|
if !d.app.covers || ref.fetch == nil {
|
||||||
d.want, d.hasCover = "", false
|
d.want, d.hasCover = "", false
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ func (v *downloadsView) nextPanel(dir int) {
|
|||||||
v.app.app.SetFocus(v.panels[0])
|
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.
|
// activate starts polling; called when the view becomes visible.
|
||||||
func (v *downloadsView) activate() {
|
func (v *downloadsView) activate() {
|
||||||
v.deactivate()
|
v.deactivate()
|
||||||
|
|||||||
@@ -51,9 +51,24 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
}
|
}
|
||||||
switch ev.Key() {
|
switch ev.Key() {
|
||||||
case tcell.KeyDown:
|
case tcell.KeyDown:
|
||||||
|
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
f.SetFocus(min(pos+1, total-1))
|
f.SetFocus(min(pos+1, total-1))
|
||||||
case tcell.KeyUp:
|
case tcell.KeyUp:
|
||||||
|
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
f.SetFocus(max(pos-1, 0))
|
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:
|
case tcell.KeyLeft, tcell.KeyRight:
|
||||||
step := 1
|
step := 1
|
||||||
if ev.Key() == tcell.KeyLeft {
|
if ev.Key() == tcell.KeyLeft {
|
||||||
@@ -118,6 +133,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
|||||||
type browseHooks struct {
|
type browseHooks struct {
|
||||||
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
|
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
|
||||||
abort func() // the list was closed without selecting
|
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
|
// browser mirrors the highlight of an open dropdown list, which tview does
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const helpText = `[yellow]Global[-]
|
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
|
Tab, Shift+Tab switch panel within a view
|
||||||
?, F9 this help (F9 works while typing)
|
?, F9 this help (F9 works while typing)
|
||||||
q, Ctrl+C quit
|
q, Ctrl+C quit
|
||||||
@@ -67,6 +67,14 @@ const helpText = `[yellow]Global[-]
|
|||||||
Popups fade away by themselves; everything they said is kept here.
|
Popups fade away by themselves; everything they said is kept here.
|
||||||
↑↓, PgUp/PgDn scroll G follow new entries c clear
|
↑↓, 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[-]
|
[yellow]Connections[-]
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
|||||||
v.render()
|
v.render()
|
||||||
})
|
})
|
||||||
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
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()
|
v.render()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -25,7 +25,6 @@ const (
|
|||||||
type searchView struct {
|
type searchView struct {
|
||||||
app *App
|
app *App
|
||||||
be backend
|
be backend
|
||||||
cfg config.Arr
|
|
||||||
|
|
||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
input *tview.InputField
|
input *tview.InputField
|
||||||
@@ -46,11 +45,10 @@ type searchView struct {
|
|||||||
loaded bool
|
loaded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
func newSearchView(app *App, be backend) *searchView {
|
||||||
v := &searchView{
|
v := &searchView{
|
||||||
app: app,
|
app: app,
|
||||||
be: be,
|
be: be,
|
||||||
cfg: cfg,
|
|
||||||
input: tview.NewInputField().SetLabel("Search: "),
|
input: tview.NewInputField().SetLabel("Search: "),
|
||||||
list: tview.NewList().ShowSecondaryText(false),
|
list: tview.NewList().ShowSecondaryText(false),
|
||||||
details: newDetailPane(app),
|
details: newDetailPane(app),
|
||||||
@@ -106,10 +104,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
|||||||
return ev
|
return ev
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.reconnect = append(app.reconnect, v.reloadOptions)
|
||||||
go v.loadOptions()
|
go v.loadOptions()
|
||||||
return v
|
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.
|
// loadOptions checks the connection and fetches the data the add form needs.
|
||||||
func (v *searchView) loadOptions() {
|
func (v *searchView) loadOptions() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
@@ -301,8 +316,8 @@ func (v *searchView) add(i int) {
|
|||||||
lSearch = "Search now"
|
lSearch = "Search now"
|
||||||
)
|
)
|
||||||
form := tview.NewForm().
|
form := tview.NewForm().
|
||||||
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
|
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
|
||||||
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
|
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
|
||||||
height := 13
|
height := 13
|
||||||
if len(modes) > 0 {
|
if len(modes) > 0 {
|
||||||
form.AddDropDown(lEpisode, modeNames, 0, nil)
|
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
|
// watchDownloads polls SABnzbd's history until ctx ends and reports finished
|
||||||
// and failed downloads as popups (and a bell, if enabled). Errors are not
|
// and failed downloads as popups (and a bell, if enabled). Errors are not
|
||||||
// reported here: the connection indicator and the log already do that.
|
// 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{}
|
seen := map[string]string{}
|
||||||
baseline := true
|
baseline := true
|
||||||
t := time.NewTicker(watchInterval)
|
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)
|
events := diffHistory(seen, slots, baseline)
|
||||||
baseline = false
|
baseline = false
|
||||||
if len(events) > 0 {
|
if len(events) > 0 {
|
||||||
a.post(func() { a.announce(events, bell) })
|
a.post(func() { a.announce(events, a.bell) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
Reference in New Issue
Block a user