Files
arr-tui/internal/ui/settings.go
T

420 lines
14 KiB
Go

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
}
var coverStyles = []string{"auto", "kitty", "sextant", "quadrant"}
// settingsFrom reads the settings in use.
func settingsFrom(c *config.Config) settingsValues {
style := c.UI.CoverStyle
if style == "" {
style = "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,
}
}
// 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 = ""
}
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 except
// the cover style of already open panes 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 connections 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"
refresh *tview.InputField
covers *tview.Checkbox
style *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)}
// 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() })
f.AddFormItem(in)
v.inputs[key] = in
}
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")
}
}
service("Radarr", "radarr", true)
service("Sonarr", "sonarr", true)
service("SABnzbd", "sabnzbd", false)
// Right below the connection settings it checks.
v.testItem = newFormAction("Test connections", 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.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.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, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
}
for key, value := range set {
v.inputs[key].SetText(value)
}
v.refresh.SetText(s.refresh)
v.covers.SetChecked(s.covers)
for i, name := range coverStyles {
if name == s.coverStyle {
v.style.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 { return v.inputs[key].GetText() }
i, _ := v.style.GetCurrentOption()
style := "auto"
if i >= 0 && i < len(coverStyles) {
style = coverStyles[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(),
}
}
// 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 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.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.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.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
v.app.Notify(levelSuccess, "Settings saved")
}