Without a config file arr-tui refused to start. It now creates an empty one (private, with its directory) and opens the Config view. A service with neither address nor key is "not configured", which validation allows: its client returns ErrNotConfigured without a request, its indicator stays grey, it is logged once as information, and its lists say so instead of showing errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
488 lines
16 KiB
Go
488 lines
16 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
|
|
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")
|
|
}
|