Config: pick the default quality profile and root folder from the server

The two defaults of Radarr and Sonarr are dropdowns filled from the
server with the address and key in the fields: when the view opens, on
Revert, and shortly after either is changed. A configured value the
server does not list stays selectable, marked "(not on the server)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 12:29:09 +02:00
co-authored by Claude Opus 5.5
parent c3eca0b68c
commit 191cfdbabb
7 changed files with 320 additions and 24 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased ## Unreleased
- **Config (F6):** the default quality profile and root folder are chosen from the lists Radarr
and Sonarr have, loaded with the address and API key in the fields (when the view opens, and
again shortly after either is changed, before saving). A configured value the server does not
have stays selectable and is marked "(not on the server)".
- **Help over the Config view:** after moving through the fields with the arrow keys, F9 left the - **Help over the Config view:** after moving through the fields with the arrow keys, F9 left the
field in use focused behind the help: its cursor showed through the window, the help did not field in use focused behind the help: its cursor showed through the window, the help did not
scroll (the form took the keys), and closing it returned to the wrong field. Arrow-key moves in scroll (the form took the keys), and closing it returned to the wrong field. Arrow-key moves in
+1 -1
View File
@@ -28,7 +28,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
- **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 (F6)**: edit everything in the config file from inside the program, in one list you move - **Config (F6)**: edit everything in the config file from inside the program, in one list you move
through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, theme, notifications. *Test* through with the arrow keys: service addresses and API keys, the add-form defaults (quality profile and root folder, picked from the lists the server has), refresh interval, posters, theme, notifications. *Test*
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a 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_*` 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. environment variables are greyed out and are never written to the file.
+1 -1
View File
@@ -69,7 +69,7 @@ Notes:
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); 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 are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); 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 are written to the Log (F7) with the reason, 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.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test` 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. - **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` 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.
- **Notifications and log (F7):** 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. - **Notifications and log (F7):** 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)
+1
View File
@@ -70,6 +70,7 @@ var helpSections = []helpSection{
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"}, {"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
{"Tab", "jump between the fields and Save / Revert"}, {"Tab", "jump between the fields and Save / Revert"},
{"Space", "toggle a checkbox; press a button"}, {"Space", "toggle a checkbox; press a button"},
{"←→, Enter", "the default quality profile and root folder: pick from the lists the server has (loaded with the address and key in the fields)"},
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"}, {"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"}, {"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
}}, }},
+45 -7
View File
@@ -121,6 +121,12 @@ type settingsView struct {
status *tview.TextView status *tview.TextView
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url" inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
choices map[string]*serverChoice // "radarr.profile", "radarr.folder", ...
// Loading the choices from the servers, per service.
choiceTimers map[string]*time.Timer
choiceGen map[string]int
choiceErr map[string]string // last error reported, so it is not repeated
refresh *tview.InputField refresh *tview.InputField
covers *tview.Checkbox covers *tview.Checkbox
style *tview.DropDown style *tview.DropDown
@@ -138,7 +144,13 @@ func settingsHints() string {
} }
func newSettingsView(app *App) *settingsView { func newSettingsView(app *App) *settingsView {
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)} v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv),
choices: map[string]*serverChoice{}, choiceTimers: map[string]*time.Timer{}, choiceGen: map[string]int{}, choiceErr: map[string]string{}}
quiet := func() func() {
was := v.filling
v.filling = true
return func() { v.filling = was }
}
// One form with every field below the other: ↑↓ moves through all of them // One form with every field below the other: ↑↓ moves through all of them
// and the form scrolls to keep the focused field in view. // and the form scrolls to keep the focused field in view.
@@ -156,17 +168,28 @@ func newSettingsView(app *App) *settingsView {
in.SetLabel(label + " (env)") in.SetLabel(label + " (env)")
in.SetDisabled(true) in.SetDisabled(true)
} }
in.SetChangedFunc(func(string) { v.edited() }) in.SetChangedFunc(func(string) {
v.edited()
// New address or key: ask that server for its profiles and folders.
if service, field, _ := strings.Cut(key, "."); service != "sabnzbd" && !v.filling && (field == "url" || field == "apikey") {
v.scheduleChoices(service)
}
})
f.AddFormItem(in) f.AddFormItem(in)
v.inputs[key] = in v.inputs[key] = in
} }
choice := func(label, key string) {
c := newServerChoice(label, quiet, v.edited)
f.AddFormItem(c)
v.choices[key] = c
}
service := func(title, name string, withDefaults bool) { service := func(title, name string, withDefaults bool) {
f.AddFormItem(newFormHeading(title)) f.AddFormItem(newFormHeading(title))
input("Address", name+".url") input("Address", name+".url")
input("API key", name+".apikey") input("API key", name+".apikey")
if withDefaults { if withDefaults {
input("Default quality profile", name+".profile") choice("Default quality profile", name+".profile")
input("Default root folder", name+".folder") choice("Default root folder", name+".folder")
} }
} }
service("Radarr", "radarr", true) service("Radarr", "radarr", true)
@@ -290,13 +313,19 @@ func (v *settingsView) fill(s settingsValues) {
v.filling = true v.filling = true
defer func() { v.filling = false }() defer func() { v.filling = false }()
set := map[string]string{ set := map[string]string{
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder, "radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder, "sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey, "sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
} }
for key, value := range set { for key, value := range set {
v.inputs[key].SetText(value) v.inputs[key].SetText(value)
} }
for key, value := range map[string]string{
"radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
"sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
} {
v.choices[key].set(value)
}
v.refresh.SetText(s.refresh) v.refresh.SetText(s.refresh)
v.covers.SetChecked(s.covers) v.covers.SetChecked(s.covers)
for i, name := range coverStyles { for i, name := range coverStyles {
@@ -316,7 +345,12 @@ func (v *settingsView) fill(s settingsValues) {
// read collects the values from the fields. // read collects the values from the fields.
func (v *settingsView) read() settingsValues { func (v *settingsView) read() settingsValues {
text := func(key string) string { return v.inputs[key].GetText() } text := func(key string) string {
if c, ok := v.choices[key]; ok {
return c.value
}
return v.inputs[key].GetText()
}
i, _ := v.style.GetCurrentOption() i, _ := v.style.GetCurrentOption()
style := "auto" style := "auto"
if i >= 0 && i < len(coverStyles) { if i >= 0 && i < len(coverStyles) {
@@ -363,6 +397,8 @@ func (v *settingsView) activate() {
if !v.dirty { if !v.dirty {
v.fill(settingsFrom(v.app.cfg)) v.fill(settingsFrom(v.app.cfg))
} }
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.focusGroup(0, false) v.focusGroup(0, false)
} }
@@ -374,6 +410,8 @@ func (v *settingsView) report(line string) {
func (v *settingsView) revert() { func (v *settingsView) revert() {
v.fill(settingsFrom(v.app.cfg)) v.fill(settingsFrom(v.app.cfg))
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.describe() v.describe()
v.report("[green]Reverted to the saved settings.[-]") v.report("[green]Reverted to the saved settings.[-]")
} }
+182
View File
@@ -0,0 +1,182 @@
package ui
import (
"context"
"strings"
"time"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// serverChoice is a setting whose values come from the server: a dropdown of
// the names Radarr/Sonarr know (quality profiles, root folders), with "(none)"
// first. The value in use is kept as an option even when the server does not
// list it (or cannot be asked), so nothing is lost by opening the view.
type serverChoice struct {
*tview.DropDown
value string // the selected value; "" = none
names []string // from the server, in its order
loaded bool // names came from the server
quiet func() func()
picked func(text string, index int) // SetOptions replaces it, so it is kept here
}
const noChoice = "(none)"
// newServerChoice makes the dropdown. quiet marks changes as not made by the
// user while the options are rebuilt; it returns the function that ends that.
func newServerChoice(label string, quiet func() func(), changed func()) *serverChoice {
c := &serverChoice{DropDown: tview.NewDropDown().SetLabel(label), quiet: quiet}
c.picked = func(_ string, i int) {
c.value = c.valueAt(i)
changed()
}
c.rebuild()
return c
}
// options lists the values in the order shown: none, the server's names, and
// the value in use when the server does not have it.
func (c *serverChoice) options() []string {
opts := append([]string{""}, c.names...)
if c.value != "" && !contains(c.names, c.value) {
opts = append(opts, c.value)
}
return opts
}
func (c *serverChoice) valueAt(i int) string {
opts := c.options()
if i < 0 || i >= len(opts) {
return ""
}
return opts[i]
}
// rebuild puts the options into the dropdown and selects the value.
func (c *serverChoice) rebuild() {
done := c.quiet()
defer done()
value := c.value
var labels []string
sel := 0
for i, o := range c.options() {
label := o
switch {
case o == "":
label = noChoice
case c.loaded && !contains(c.names, o):
label = o + " (not on the server)"
}
labels = append(labels, label)
if o == value {
sel = i
}
}
c.value = value
c.SetOptions(labels, c.picked)
c.SetCurrentOption(sel) // reports sel, which is value
}
// set selects a value.
func (c *serverChoice) set(value string) {
c.value = strings.TrimSpace(value)
c.rebuild()
}
// setNames replaces the server's names; nil with loaded false means they are
// not known.
func (c *serverChoice) setNames(names []string, loaded bool) {
c.names, c.loaded = names, loaded
c.rebuild()
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// choicesDelay is how long typing in an address or API key pauses before the
// server is asked for its profiles and folders.
const choicesDelay = 700 * time.Millisecond
// scheduleChoices asks the service again once its address and key have not
// changed for choicesDelay.
func (v *settingsView) scheduleChoices(service string) {
if t := v.choiceTimers[service]; t != nil {
t.Stop()
}
v.choiceTimers[service] = time.AfterFunc(choicesDelay, func() {
v.app.post(func() { v.loadChoices(service) })
})
}
// loadChoices fills the profile and folder dropdowns of a service ("radarr" or
// "sonarr") from the server, using the address and key typed in the fields.
func (v *settingsView) loadChoices(service string) {
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
url := strings.TrimSpace(v.inputs[service+".url"].GetText())
key := strings.TrimSpace(v.inputs[service+".apikey"].GetText())
v.choiceGen[service]++
gen := v.choiceGen[service]
if url == "" || key == "" {
profile.setNames(nil, false)
folder.setNames(nil, false)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
profiles, folders, err := fetchChoices(ctx, url, key)
v.app.post(func() { v.applyChoices(service, gen, profiles, folders, err) })
}()
}
// fetchChoices asks a Radarr/Sonarr for its quality profile names and root
// folder paths.
func fetchChoices(ctx context.Context, url, key string) (profiles, folders []string, err error) {
client := arr.NewClient(url, key)
qp, err := client.QualityProfiles(ctx)
if err != nil {
return nil, nil, err
}
rf, err := client.RootFolders(ctx)
if err != nil {
return nil, nil, err
}
for _, p := range qp {
profiles = append(profiles, p.Name)
}
for _, f := range rf {
folders = append(folders, f.Path)
}
return profiles, folders, nil
}
// applyChoices puts what fetchChoices found into the dropdowns, unless the
// address or key changed since it was asked (gen).
func (v *settingsView) applyChoices(service string, gen int, profiles, folders []string, err error) {
if v.choiceGen[service] != gen {
return
}
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
if err != nil {
profile.setNames(nil, false)
folder.setNames(nil, false)
name := map[string]string{"radarr": "Radarr", "sonarr": "Sonarr"}[service]
if msg := err.Error(); v.choiceErr[service] != msg {
v.choiceErr[service] = msg
v.report("[yellow]" + name + ": quality profiles and root folders not loaded: " + tview.Escape(msg) + "[-]")
}
return
}
delete(v.choiceErr, service)
profile.setNames(profiles, true)
folder.setNames(folders, true)
}
+75 -4
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"errors"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
"net/http" "net/http"
@@ -133,7 +134,8 @@ func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
// Edit the fields like a user would and save. // Edit the fields like a user would and save.
v.inputs["radarr.url"].SetText(radarr.URL) v.inputs["radarr.url"].SetText(radarr.URL)
v.inputs["radarr.apikey"].SetText("brand-new-key") v.inputs["radarr.apikey"].SetText("brand-new-key")
v.inputs["sonarr.profile"].SetText("Any") v.choices["sonarr.profile"].set("Any")
v.edited()
v.refresh.SetText("7") v.refresh.SetText("7")
v.bell.SetChecked(true) v.bell.SetChecked(true)
if !v.dirty { if !v.dirty {
@@ -319,10 +321,9 @@ func TestHelpOverConfigTakesTheFocus(t *testing.T) {
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0)) a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
moveDown := a.settings.form.GetInputCapture() moveDown := a.settings.form.GetInputCapture()
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0)) moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0)) field := a.settings.inputs["radarr.apikey"]
field := a.settings.inputs["radarr.profile"]
if a.app.GetFocus() != field { if a.app.GetFocus() != field {
t.Fatalf("after ↓↓ the application's focus is %T, not the third field", a.app.GetFocus()) t.Fatalf("after ↓ the application's focus is %T, not the second field", a.app.GetFocus())
} }
a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0)) a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0))
@@ -340,3 +341,73 @@ func TestHelpOverConfigTakesTheFocus(t *testing.T) {
t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus()) t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus())
} }
} }
func TestDefaultsAreChosenFromTheServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Header.Get("X-Api-Key") != "key":
w.WriteHeader(http.StatusUnauthorized)
case strings.HasSuffix(r.URL.Path, "/qualityprofile"):
w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`))
case strings.HasSuffix(r.URL.Path, "/rootfolder"):
w.Write([]byte(`[{"id":1,"path":"/movies"}]`))
}
}))
defer srv.Close()
profiles, folders, err := fetchChoices(t.Context(), srv.URL, "key")
if err != nil || strings.Join(profiles, ",") != "Any,HD-1080p" || strings.Join(folders, ",") != "/movies" {
t.Fatalf("fetched %v %v %v", profiles, folders, err)
}
if _, _, err := fetchChoices(t.Context(), srv.URL, "wrong"); err == nil {
t.Error("a wrong key must fail")
}
a := newTestApp(t)
v := a.settings
p := v.choices["radarr.profile"]
p.set("Old profile") // configured, but not on the server
v.dirty = false
v.choiceGen["radarr"] = 1
v.applyChoices("radarr", 1, profiles, folders, nil)
n := p.GetOptionCount()
var labels []string
for i := 0; i < n; i++ {
labels = append(labels, p.valueAt(i))
}
if strings.Join(labels, "|") != "|Any|HD-1080p|Old profile" {
t.Errorf("options %q", labels)
}
if _, text := p.GetCurrentOption(); text != "Old profile (not on the server)" || p.value != "Old profile" {
t.Errorf("selected %q, value %q", text, p.value)
}
if v.dirty {
t.Error("loading the options is not an edit")
}
// Choosing one is an edit and is what gets saved.
p.SetCurrentOption(2)
if p.value != "HD-1080p" || !v.dirty || v.read().radarrProfile != "HD-1080p" {
t.Errorf("chosen %q dirty %v", p.value, v.dirty)
}
// An answer for an address that has changed since is dropped.
v.choiceGen["radarr"] = 2
v.applyChoices("radarr", 1, nil, nil, nil)
if p.GetOptionCount() != n {
t.Error("stale answer applied")
}
// Typing a new address asks that server again (after a pause); SABnzbd has no such lists.
v.inputs["sonarr.url"].SetText("http://elsewhere:8989")
v.inputs["sabnzbd.url"].SetText("http://elsewhere:8080")
if v.choiceTimers["sonarr"] == nil || v.choiceTimers["sabnzbd"] != nil {
t.Errorf("reload scheduled: %v", v.choiceTimers)
}
v.choiceTimers["sonarr"].Stop() // the test has no event loop to deliver it
// Without the server only none and the value in use are offered.
v.applyChoices("radarr", 2, nil, nil, errors.New("connection refused"))
if p.GetOptionCount() != 2 || !strings.Contains(v.status.GetText(false), "connection refused") {
t.Errorf("offline: %d options, result %q", p.GetOptionCount(), v.status.GetText(false))
}
}