Files
arr-tui/internal/ui/settings_choice.go
nilsandClaude Opus 5.5 191cfdbabb 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
2026-10-21 12:29:09 +02:00

183 lines
5.2 KiB
Go

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)
}