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 theme string } var ( coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"} themes = []string{"auto", "dark", "light"} ) // settingsFrom reads the settings in use. func settingsFrom(c *config.Config) settingsValues { style, th := c.UI.CoverStyle, c.UI.Theme if style == "" { style = "auto" } if th == "" { th = "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, theme: th, } } // 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 = "" } raw.UI.Theme = v.theme if v.theme == "auto" { raw.UI.Theme = "" } 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 applies // right after saving. type settingsView struct { app *App root *tview.Flex form *tview.Form // every field, one below the other, with section headings testItem *formAction // the Test button, below the service settings actions *tview.Form // the buttons below it 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" 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 { return "↑↓ move through the fields · Space toggles or presses a button · Tab jumps between fields and Save/Revert" } func newSettingsView(app *App) *settingsView { 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. f := tview.NewForm().SetItemPadding(0) f.SetBorder(true).SetTitle(" Settings ") f.SetBorderPadding(0, 0, 1, 1) // one row per field v.form = f input := func(label, key string) { in := tview.NewInputField().SetLabel(label) 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() // 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 { choice("Default quality profile", name+".profile") choice("Default root folder", name+".folder") } } service("Radarr", "radarr", true) service("Sonarr", "sonarr", true) service("SABnzbd", "sabnzbd", false) // Right below the connection settings it checks. v.testItem = newFormAction("Test", v.test) f.AddFormItem(v.testItem) f.AddFormItem(newFormHeading("Interface")) v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6) v.refresh.SetChangedFunc(func(string) { v.edited() }) f.AddFormItem(v.refresh) v.covers = tview.NewCheckbox().SetLabel("Posters").SetChangedFunc(func(bool) { v.edited() }) f.AddFormItem(v.covers) v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() }) f.AddFormItem(v.style) v.theme = tview.NewDropDown().SetLabel("Theme").SetOptions(themes, func(string, int) { v.edited() }) f.AddFormItem(v.theme) v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() }) f.AddFormItem(v.notify) v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() }) f.AddFormItem(v.bell) v.showKeys = tview.NewCheckbox().SetLabel("Show API keys").SetChangedFunc(func(shown bool) { v.maskKeys(!shown) }) f.AddFormItem(v.showKeys) v.actions = tview.NewForm().SetItemPadding(0) v.actions.SetBorder(true).SetTitle(" Actions ") v.actions.SetBorderPadding(0, 0, 1, 1) v.actions.SetButtonsAlign(tview.AlignCenter) 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.form, 0, 1, true). AddItem(v.actions, 4, 0, false) v.root = tview.NewFlex().AddItem(left, 0, 3, true).AddItem(v.status, 0, 2, false) v.groups = []*tview.Form{v.form, v.actions} for i, g := range v.groups { i := i arrowNavigation(g, func() {}, &browseHooks{ // ↓ past the last field reaches the buttons, ↑ from them goes back. 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 group, 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 := firstFocusable(g) 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, "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 { if name == s.coverStyle { v.style.SetCurrentOption(i) } } for i, name := range themes { if name == s.theme { v.theme.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 { 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) { style = coverStyles[i] } th := "auto" if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) { th = themes[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(), theme: th, } } // 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 !v.app.cfg.AnyConfigured() { b.WriteString("[yellow]No service is set up yet.[-] Enter the address and API key of Radarr, Sonarr and/or SABnzbd (Settings → General in each app), then Save. A service left empty is simply not used.\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.loadChoices("radarr") v.loadChoices("sonarr") 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.loadChoices("radarr") v.loadChoices("sonarr") 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.describe() // what it says may depend on the settings (e.g. nothing set up yet) v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]") v.app.Notify(levelSuccess, "Settings saved") }