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:
@@ -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
@@ -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.[-]")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user