420 lines
14 KiB
Go
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
|
|
|
|
radarr, sonarr, sab, ui, actions *tview.Form
|
|
groups []*tview.Form // keyboard order
|
|
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 · Tab next section · Space toggles · Enter presses a button"
|
|
}
|
|
|
|
func newSettingsView(app *App) *settingsView {
|
|
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
|
|
|
section := func(title string) *tview.Form {
|
|
f := tview.NewForm().SetItemPadding(0)
|
|
f.SetBorder(true).SetTitle(" " + title + " ")
|
|
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
|
return f
|
|
}
|
|
input := func(f *tview.Form, label, key string, width int) {
|
|
in := tview.NewInputField().SetLabel(label).SetFieldWidth(width)
|
|
in.SetLabelWidth(24) // the same column in every section
|
|
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(f *tview.Form, name string, withDefaults bool) {
|
|
input(f, "Address", name+".url", 0)
|
|
input(f, "API key", name+".apikey", 0)
|
|
if withDefaults {
|
|
input(f, "Default quality profile", name+".profile", 0)
|
|
input(f, "Default root folder", name+".folder", 0)
|
|
}
|
|
}
|
|
v.radarr, v.sonarr, v.sab = section("Radarr"), section("Sonarr"), section("SABnzbd")
|
|
service(v.radarr, "radarr", true)
|
|
service(v.sonarr, "sonarr", true)
|
|
service(v.sab, "sabnzbd", false)
|
|
|
|
v.ui = section("Interface")
|
|
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
|
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
|
v.ui.AddFormItem(v.refresh)
|
|
v.ui.AddCheckbox("Posters", true, func(bool) { v.edited() })
|
|
v.ui.AddDropDown("Poster style", coverStyles, 0, func(string, int) { v.edited() })
|
|
v.ui.AddCheckbox("Notify about finished downloads", true, func(bool) { v.edited() })
|
|
v.ui.AddCheckbox("Ring the terminal bell too", false, func(bool) { v.edited() })
|
|
v.ui.AddCheckbox("Show API keys", false, func(shown bool) { v.maskKeys(!shown) })
|
|
v.covers = v.ui.GetFormItem(1).(*tview.Checkbox)
|
|
v.style = v.ui.GetFormItem(2).(*tview.DropDown)
|
|
v.notify = v.ui.GetFormItem(3).(*tview.Checkbox)
|
|
v.bell = v.ui.GetFormItem(4).(*tview.Checkbox)
|
|
v.showKeys = v.ui.GetFormItem(5).(*tview.Checkbox)
|
|
|
|
v.actions = tview.NewForm().SetItemPadding(0)
|
|
v.actions.SetBorder(true).SetTitle(" Actions ")
|
|
v.actions.SetBorderPadding(0, 0, 1, 1)
|
|
v.actions.AddButton("Test connections", v.test)
|
|
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.radarr, 6, 0, true).
|
|
AddItem(v.sonarr, 6, 0, false).
|
|
AddItem(v.sab, 4, 0, false)
|
|
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(v.ui, 8, 0, false).
|
|
AddItem(v.actions, 4, 0, false).
|
|
AddItem(v.status, 0, 1, false)
|
|
v.root = tview.NewFlex().AddItem(left, 0, 1, true).AddItem(right, 0, 1, false)
|
|
|
|
v.groups = []*tview.Form{v.radarr, v.sonarr, v.sab, v.ui, v.actions}
|
|
for i, g := range v.groups {
|
|
i := i
|
|
arrowNavigation(g, func() {}, &browseHooks{
|
|
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 section, 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 := 0
|
|
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")
|
|
}
|