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
+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"},
{"Tab", "jump between the fields and Save / Revert"},
{"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"},
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
}},
+56 -18
View File
@@ -120,17 +120,23 @@ type settingsView struct {
groups []*tview.Form // keyboard order: the fields, then the buttons
status *tview.TextView
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
refresh *tview.InputField
covers *tview.Checkbox
style *tview.DropDown
theme *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
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
covers *tview.Checkbox
style *tview.DropDown
theme *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 {
@@ -138,7 +144,13 @@ func settingsHints() string {
}
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
// 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.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)
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) {
f.AddFormItem(newFormHeading(title))
input("Address", name+".url")
input("API key", name+".apikey")
if withDefaults {
input("Default quality profile", name+".profile")
input("Default root folder", name+".folder")
choice("Default quality profile", name+".profile")
choice("Default root folder", name+".folder")
}
}
service("Radarr", "radarr", true)
@@ -290,13 +313,19 @@ 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,
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
}
for key, value := range set {
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.covers.SetChecked(s.covers)
for i, name := range coverStyles {
@@ -316,7 +345,12 @@ func (v *settingsView) fill(s settingsValues) {
// read collects the values from the fields.
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()
style := "auto"
if i >= 0 && i < len(coverStyles) {
@@ -363,6 +397,8 @@ func (v *settingsView) activate() {
if !v.dirty {
v.fill(settingsFrom(v.app.cfg))
}
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.focusGroup(0, false)
}
@@ -374,6 +410,8 @@ func (v *settingsView) report(line string) {
func (v *settingsView) revert() {
v.fill(settingsFrom(v.app.cfg))
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.describe()
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
import (
"errors"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"net/http"
@@ -133,7 +134,8 @@ func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
// 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.choices["sonarr.profile"].set("Any")
v.edited()
v.refresh.SetText("7")
v.bell.SetChecked(true)
if !v.dirty {
@@ -319,10 +321,9 @@ func TestHelpOverConfigTakesTheFocus(t *testing.T) {
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
moveDown := a.settings.form.GetInputCapture()
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
field := a.settings.inputs["radarr.profile"]
field := a.settings.inputs["radarr.apikey"]
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))
@@ -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())
}
}
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))
}
}