Add the Config view: edit and save the settings, applied without a restart

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:31:13 +02:00
co-authored by Claude Sonnet 5.5
parent 0ccd02b3c5
commit a4ec9ad71e
25 changed files with 1168 additions and 54 deletions
+70 -22
View File
@@ -25,6 +25,9 @@ type view struct {
onShow, onHide func()
// hints lists the keys that work in this view (shown in the status bar).
hints func() string
// captureKeys makes the global single-key shortcuts (q, ?) stand back
// while this view is shown, because its fields take typed text.
captureKeys bool
}
// App is the top-level arr-tui application.
@@ -46,8 +49,18 @@ type App struct {
state map[string]serviceState // keyed by service name
probes []probe
screen tcell.Screen // remembered at the first draw, for the bell
screen tcell.Screen // remembered at the first draw, for the bell
cfg *config.Config // the settings in use (file, environment and defaults combined)
cfgPath string // the config file, empty if unknown
cfgFile *config.Config // the file's contents as written, for saving
radarr *radarr.Client
sonarr *sonarr.Client
sab *sabnzbd.Client
downloads *downloadsView
settings *settingsView
panes []*detailPane // every detail pane, to restyle posters when settings change
reconnect []func() // run after the service addresses or keys changed
watchStop context.CancelFunc
notifyDone bool // announce finished downloads
bell bool
covers bool // poster thumbnails enabled
@@ -111,8 +124,10 @@ func New(cfg *config.Config) *App {
a.gfx = newKittyGraphics(a.writeTerminal)
}
a.cfg = cfg
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.radarr, a.sonarr = radarrClient, sonarrClient
a.logs = newLogView(a)
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
@@ -127,6 +142,8 @@ func New(cfg *config.Config) *App {
}},
{"SABnzbd", sabClient.Version},
}
settings := newSettingsView(a)
a.settings = settings
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient,
@@ -135,13 +152,15 @@ func New(cfg *config.Config) *App {
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
},
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads
a.views = []view{
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
{key: '6', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
{key: '7', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints, captureKeys: true},
}
for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0)
@@ -236,6 +255,7 @@ func (a *App) setNarrow(narrow bool) {
a.narrow = narrow
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
a.renderServices()
a.renderTabs()
}
func (a *App) renderServices() {
@@ -295,27 +315,31 @@ func (a *App) Run() error {
}
}()
go a.watchServices(stop)
if a.notifyDone {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.watchDownloads(ctx, a.sab, a.bell)
}
a.setNotifyDownloads(a.notifyDone)
defer a.setNotifyDownloads(false)
return a.app.Run()
}
// setNotifyDownloads starts or stops the background watch for finished downloads.
func (a *App) setNotifyDownloads(on bool) {
a.notifyDone = on
if a.watchStop != nil {
a.watchStop()
a.watchStop = nil
}
if on {
ctx, cancel := context.WithCancel(context.Background())
a.watchStop = cancel
go a.watchDownloads(ctx, a.sab)
}
}
// watchServices checks all services now and then every heartbeat until stop is closed.
func (a *App) watchServices(stop <-chan struct{}) {
tick := time.NewTicker(heartbeat)
defer tick.Stop()
for {
for _, p := range a.probes {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := p.check(ctx)
a.post(func() { a.SetService(p.name, version, err) })
}()
}
a.probeAll()
select {
case <-tick.C:
case <-stop:
@@ -324,15 +348,34 @@ func (a *App) watchServices(stop <-chan struct{}) {
}
}
// probeAll checks every service in the background and updates the indicators.
func (a *App) probeAll() {
for _, p := range a.probes {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := p.check(ctx)
a.post(func() { a.SetService(p.name, version, err) })
}()
}
}
func (a *App) renderStatus() {
var hints string
if n := len(a.overlays); n > 0 {
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
} else {
if h := a.views[a.active].hints; h != nil {
hints = h() + " · "
v := a.views[a.active]
var parts []string
if v.hints != nil {
parts = append(parts, v.hints())
}
hints += "? help · F1-F6 · q quit"
if v.captureKeys { // q and ? are text there
parts = append(parts, "F9 help · F1-F7 views")
} else {
parts = append(parts, "? help · F1-F7 · q quit")
}
hints = strings.Join(parts, " · ")
}
a.status.SetText("[gray]" + hints)
}
@@ -419,7 +462,7 @@ func (a *App) onlyFullOverlays() bool {
// typing reports whether keystrokes belong to a text field or dialog and must
// not trigger global shortcuts.
func (a *App) typing() bool {
if len(a.overlays) > 0 {
if len(a.overlays) > 0 || a.views[a.active].captureKeys {
return true
}
_, isInput := a.app.GetFocus().(*tview.InputField)
@@ -483,9 +526,14 @@ func (a *App) switchTo(i int) {
func (a *App) renderTabs() {
var s string
for i, v := range a.views {
if i == a.active {
switch {
case a.narrow && i == a.active:
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active:
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
} else {
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ func newTestApp(t *testing.T) *App {
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
a := newTestApp(t)
for _, r := range "123456\t" {
for _, r := range "1234567\t" {
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
if r == '\t' {
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
@@ -32,7 +32,7 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
t.Fatalf("key %q switched to view %d", r, a.active)
}
}
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6} {
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6, tcell.KeyF7} {
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
}
+1
View File
@@ -209,6 +209,7 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
v.table.SetSelectedFunc(func(int, int) { v.open() })
v.table.SetInputCapture(v.handleKey)
app.reconnect = append(app.reconnect, func() { v.entries, v.loaded = nil, false })
v.render()
return v
}
+66
View File
@@ -0,0 +1,66 @@
package ui
import (
"os"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
)
// SetConfigSource tells the app which file the settings came from and what that
// file contains, so the Config view can save changes. raw is the file as
// written (see config.LoadFile), not the effective settings.
func (a *App) SetConfigSource(path string, raw *config.Config) {
a.cfgPath, a.cfgFile = path, raw
if a.settings != nil {
a.settings.describe()
}
}
// applyConfig puts new settings into effect without a restart.
func (a *App) applyConfig(c *config.Config) {
old := a.cfg
a.cfg = c
if old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service || old.SABnzbd != c.SABnzbd {
a.radarr.Reconfigure(c.Radarr.URL, c.Radarr.APIKey)
a.sonarr.Reconfigure(c.Sonarr.URL, c.Sonarr.APIKey)
a.sab.Reconfigure(c.SABnzbd.URL, c.SABnzbd.APIKey)
a.probeAll() // the indicators, right away
for _, f := range a.reconnect {
f()
}
}
if old.UI.RefreshSeconds != c.UI.RefreshSeconds {
a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second)
}
a.bell = c.UI.Bell
if c.UI.ShouldNotifyDownloads() != a.notifyDone {
a.setNotifyDownloads(c.UI.ShouldNotifyDownloads())
}
if old.UI.ShowCovers() != c.UI.ShowCovers() || old.UI.CoverStyle != c.UI.CoverStyle {
a.covers = c.UI.ShowCovers()
a.restyleCovers()
}
}
// restyleCovers applies a changed poster style or switch to every detail pane.
func (a *App) restyleCovers() {
blocks, graphics := resolveCovers(a.cfg.UI.CoverStyle, os.Getenv)
if a.gfx != nil {
a.gfx.deleteAll() // the terminal's copies belong to the old images
}
a.coverStyle = blocks
switch {
case graphics && a.gfx == nil:
a.gfx = newKittyGraphics(a.writeTerminal)
case !graphics:
a.gfx = nil
}
a.coverCache = newCoverCache() // stored copies depend on the style (their height)
for _, p := range a.panes {
p.cover.style, p.cover.gfx = blocks, a.gfx
p.cover.cells = nil
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
}
}
+5 -4
View File
@@ -7,6 +7,7 @@ import (
"image/color"
"image/jpeg"
"image/png"
"sync/atomic"
"testing"
"time"
@@ -158,11 +159,11 @@ func TestCoverViewDraws(t *testing.T) {
func TestLoadCoverCachesAndDedupes(t *testing.T) {
a := newTestApp(t)
fetches := 0
var fetches atomic.Int32
var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
fetches++
fetches.Add(1)
return buf.Bytes(), nil
}}
@@ -173,8 +174,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
if len(a.coverCache.waiting["k"]) != 2 {
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
}
if fetches > 1 {
t.Errorf("fetched %d times", fetches)
if n := fetches.Load(); n > 1 {
t.Errorf("fetched %d times", n)
}
}
+5 -3
View File
@@ -27,8 +27,9 @@ type detailPane struct {
cover *coverView
detail detailText
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
lastRef coverRef // the poster of the current detail, to fetch it again after a style change
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
// What the text was last wrapped for.
laidOut struct {
@@ -47,12 +48,13 @@ func newDetailPane(app *App) *detailPane {
}
d.AddItem(d.text, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ")
app.panes = append(app.panes, d)
return d
}
// Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text detailText, ref coverRef) {
d.detail = text
d.detail, d.lastRef = text, ref
d.laidOut.valid = false
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
+8
View File
@@ -134,6 +134,14 @@ func (v *downloadsView) nextPanel(dir int) {
v.app.app.SetFocus(v.panels[0])
}
// setInterval changes the polling interval, also while polling.
func (v *downloadsView) setInterval(d time.Duration) {
v.interval = d
if v.cancel != nil {
v.activate()
}
}
// activate starts polling; called when the view becomes visible.
func (v *downloadsView) activate() {
v.deactivate()
+20
View File
@@ -51,9 +51,24 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
}
switch ev.Key() {
case tcell.KeyDown:
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
return nil
}
f.SetFocus(min(pos+1, total-1))
case tcell.KeyUp:
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
return nil
}
f.SetFocus(max(pos-1, 0))
case tcell.KeyTab, tcell.KeyBacktab:
dir := 1
if ev.Key() == tcell.KeyBacktab {
dir = -1
}
if hooks != nil && hooks.tab != nil && hooks.tab(dir) {
return nil
}
return ev
case tcell.KeyLeft, tcell.KeyRight:
step := 1
if ev.Key() == tcell.KeyLeft {
@@ -118,6 +133,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
type browseHooks struct {
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
abort func() // the list was closed without selecting
// edge is called when Down is pressed on the last field or Up on the first;
// it returns true if it moved the focus elsewhere.
edge func(down bool) bool
// tab is called for Tab (dir 1) and Shift+Tab (dir -1); true if handled.
tab func(dir int) bool
}
// browser mirrors the highlight of an open dropdown list, which tview does
+9 -1
View File
@@ -6,7 +6,7 @@ import (
)
const helpText = `[yellow]Global[-]
F1-F6 switch view (also from the release and episode lists)
F1-F7 switch view (also from the release and episode lists)
Tab, Shift+Tab switch panel within a view
?, F9 this help (F9 works while typing)
q, Ctrl+C quit
@@ -67,6 +67,14 @@ const helpText = `[yellow]Global[-]
Popups fade away by themselves; everything they said is kept here.
↑↓, PgUp/PgDn scroll G follow new entries c clear
[yellow]Config (F7)[-]
Edit the settings of the config file: service addresses and API keys, add-form
defaults, refresh interval, posters, notifications.
↑↓ move, Tab next section, Space toggles, Enter presses a button
Test connections checks the three services with the values typed in
Save writes the file and applies everything at once; Revert discards edits
Letters such as q and ? are typed here; F9 opens this help
[yellow]Connections[-]
`
+8
View File
@@ -263,6 +263,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.render()
})
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
app.reconnect = append(app.reconnect, func() {
// Another server: forget what the old one said, reload when shown.
v.seriesFiles = map[int]seriesFiles{}
for _, p := range v.panes {
p.all, p.shown, p.loaded, p.err = nil, nil, false, nil
}
v.render()
})
v.render()
return v
}
+20 -5
View File
@@ -25,7 +25,6 @@ const (
type searchView struct {
app *App
be backend
cfg config.Arr
root *tview.Flex
input *tview.InputField
@@ -46,11 +45,10 @@ type searchView struct {
loaded bool
}
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
func newSearchView(app *App, be backend) *searchView {
v := &searchView{
app: app,
be: be,
cfg: cfg,
input: tview.NewInputField().SetLabel("Search: "),
list: tview.NewList().ShowSecondaryText(false),
details: newDetailPane(app),
@@ -106,10 +104,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
return ev
})
app.reconnect = append(app.reconnect, v.reloadOptions)
go v.loadOptions()
return v
}
// defaults returns the add-form defaults of the service, as currently configured.
func (v *searchView) defaults() config.Arr {
if v.be.name() == "Radarr" {
return v.app.cfg.Radarr
}
return v.app.cfg.Sonarr
}
// reloadOptions fetches the profiles and folders again, after the service changed.
func (v *searchView) reloadOptions() {
v.mu.Lock()
v.loaded = false
v.mu.Unlock()
go v.loadOptions()
}
// loadOptions checks the connection and fetches the data the add form needs.
func (v *searchView) loadOptions() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
@@ -301,8 +316,8 @@ func (v *searchView) add(i int) {
lSearch = "Search now"
)
form := tview.NewForm().
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
height := 13
if len(modes) > 0 {
form.AddDropDown(lEpisode, modeNames, 0, nil)
+419
View File
@@ -0,0 +1,419 @@
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")
}
+233
View File
@@ -0,0 +1,233 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
)
func baseRaw() config.Config {
return config.Config{
Radarr: config.Arr{Service: config.Service{URL: "http://r:7878", APIKey: "rk"}},
Sonarr: config.Arr{Service: config.Service{URL: "http://s:8989", APIKey: "sk"}},
SABnzbd: config.Service{URL: "http://a:8080", APIKey: "ak"},
}
}
func TestApplySettings(t *testing.T) {
raw := baseRaw()
v := settingsFrom(mustEffective(t, raw))
if v.refresh != "3" || !v.covers || !v.notify || v.bell || v.coverStyle != "auto" {
t.Fatalf("defaults shown wrong: %+v", v)
}
// Unchanged settings add nothing but the refresh interval to the file.
got, err := applySettings(raw, v, nil)
if err != nil {
t.Fatal(err)
}
if got.UI.Covers != nil || got.UI.NotifyDownloads != nil || got.UI.CoverStyle != "" || got.UI.Bell {
t.Errorf("defaults written to the file: %+v", got.UI)
}
// Edits are applied and whitespace is trimmed.
v.radarrURL = " http://new:7878 "
v.radarrProfile = " HD-1080p "
v.sabKey = "newkey"
v.refresh = "10"
v.covers = false
v.notify = false
v.bell = true
v.coverStyle = "quadrant"
got, err = applySettings(raw, v, nil)
if err != nil {
t.Fatal(err)
}
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" {
t.Errorf("edits lost: %+v", got)
}
// A setting that was explicit in the file stays explicit, even at its default.
yes := true
raw.UI.Covers = &yes
v.covers = true
got, _ = applySettings(raw, v, nil)
if got.UI.Covers == nil || !*got.UI.Covers {
t.Error("an explicit setting was dropped")
}
}
func TestApplySettingsValidates(t *testing.T) {
v := settingsFrom(mustEffective(t, baseRaw()))
for name, mutate := range map[string]func(*settingsValues){
"refresh not a number": func(v *settingsValues) { v.refresh = "often" },
"refresh zero": func(v *settingsValues) { v.refresh = "0" },
"refresh too large": func(v *settingsValues) { v.refresh = "99999" },
"address not http": func(v *settingsValues) { v.sonarrURL = "ftp://x" },
"empty api key": func(v *settingsValues) { v.radarrKey = " " },
"empty address": func(v *settingsValues) { v.sabURL = "" },
} {
w := v
mutate(&w)
if _, err := applySettings(baseRaw(), w, nil); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestApplySettingsKeepsEnvironmentValuesOutOfTheFile(t *testing.T) {
raw := baseRaw()
// The fields show the key from the environment...
shown := settingsFrom(mustEffective(t, raw))
shown.radarrKey = "secret-from-env"
shown.radarrURL = "http://typed-over-env"
got, err := applySettings(raw, shown, map[string]string{"radarr.apikey": "ARR_TUI_RADARR_APIKEY", "radarr.url": "ARR_TUI_RADARR_URL"})
if err != nil {
t.Fatal(err)
}
// ...but the file keeps what it had.
if got.Radarr.APIKey != "rk" || got.Radarr.URL != "http://r:7878" {
t.Errorf("environment values leaked into the file: %+v", got.Radarr)
}
}
func mustEffective(t *testing.T, raw config.Config) *config.Config {
t.Helper()
eff, err := raw.Effective(func(string) string { return "" })
if err != nil {
t.Fatal(err)
}
return eff
}
func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "") // make sure the environment does not interfere
var gotKey atomic.Value // the app also probes the server in the background
radarr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey.Store(r.Header.Get("X-Api-Key"))
w.Write([]byte(`{"version":"6.0"}`))
}))
defer radarr.Close()
a := newTestApp(t)
path := filepath.Join(t.TempDir(), "config.json")
raw := baseRaw()
if err := config.Save(path, &raw); err != nil {
t.Fatal(err)
}
loaded, _ := config.LoadFile(path)
a.SetConfigSource(path, loaded)
v := a.settings
v.fill(settingsFrom(a.cfg))
// Edit the fields like a user would and save.
v.inputs["radarr.url"].SetText(radarr.URL)
v.inputs["radarr.apikey"].SetText("brand-new-key")
v.inputs["sonarr.profile"].SetText("Any")
v.refresh.SetText("7")
v.bell.SetChecked(true)
if !v.dirty {
t.Error("editing must mark the form as changed")
}
v.save()
// 1. The file has the new values and stays private.
saved, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
if saved.Radarr.URL != radarr.URL || saved.Radarr.APIKey != "brand-new-key" || saved.Sonarr.DefaultQualityProfile != "Any" || saved.UI.RefreshSeconds != 7 || !saved.UI.Bell {
t.Errorf("file = %+v", saved)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
t.Errorf("mode %v", info.Mode().Perm())
}
// 2. The running app uses them at once.
if a.cfg.Radarr.URL != radarr.URL || !a.bell || a.cfg.UI.RefreshSeconds != 7 {
t.Errorf("settings not applied: %+v", a.cfg)
}
if _, err := a.radarr.SystemStatus(t.Context()); err != nil || gotKey.Load() != "brand-new-key" {
t.Errorf("the client still talks to the old instance: key=%v err=%v", gotKey.Load(), err)
}
if got := a.downloads.interval.Seconds(); got != 7 {
t.Errorf("downloads interval = %v", got)
}
if v.dirty {
t.Error("saving must clear the changed mark")
}
if !strings.Contains(v.status.GetText(false), "Saved") {
t.Errorf("result box: %q", v.status.GetText(false))
}
// An invalid edit is refused and leaves the file alone.
before, _ := os.ReadFile(path)
v.refresh.SetText("soon")
v.save()
after, _ := os.ReadFile(path)
if string(before) != string(after) || !strings.Contains(v.status.GetText(false), "Not saved") {
t.Errorf("invalid settings were saved: %q", v.status.GetText(false))
}
// Revert goes back to what is in use.
v.revert()
if v.refresh.GetText() != "7" || v.dirty {
t.Errorf("revert: refresh=%q dirty=%v", v.refresh.GetText(), v.dirty)
}
}
func TestSettingsViewWithoutConfigFile(t *testing.T) {
a := newTestApp(t)
a.settings.save()
if !strings.Contains(a.settings.status.GetText(false), "no config file") {
t.Errorf("result box: %q", a.settings.status.GetText(false))
}
}
func TestSettingsViewFocusMovesBetweenSections(t *testing.T) {
a := newTestApp(t)
v := a.settings
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
v.focusGroup(3, false) // Interface
for _, g := range v.groups {
g.Focus(focus)
}
// Whatever flags were left behind, moving to the actions must leave exactly one section focused.
v.focusGroup(4, false)
focused := 0
for _, g := range v.groups {
if g.HasFocus() {
focused++
}
}
if focused != 1 || !v.actions.HasFocus() {
t.Errorf("%d sections claim the focus, want only the actions", focused)
}
// Down on the last field of a section continues in the next one; Tab cycles.
v.focusGroup(0, false)
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
g := v.radarr
g.SetFocus(g.GetFormItemCount() - 1)
send(g, tcell.KeyDown)
if !v.sonarr.HasFocus() || v.radarr.HasFocus() {
t.Error("Down on the last field must move to the next section")
}
send(v.sonarr, tcell.KeyBacktab)
if !v.radarr.HasFocus() || v.sonarr.HasFocus() {
t.Error("Shift+Tab must move back a section")
}
send(v.radarr, tcell.KeyUp) // first section, first field: nothing before it
if !v.radarr.HasFocus() {
t.Error("Up on the first field of the first section must stay put")
}
}
+2 -2
View File
@@ -41,7 +41,7 @@ func diffHistory(seen map[string]string, slots []sabnzbd.HistorySlot, baseline b
// watchDownloads polls SABnzbd's history until ctx ends and reports finished
// and failed downloads as popups (and a bell, if enabled). Errors are not
// reported here: the connection indicator and the log already do that.
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client, bell bool) {
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client) {
seen := map[string]string{}
baseline := true
t := time.NewTicker(watchInterval)
@@ -54,7 +54,7 @@ func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client, bell bool
events := diffHistory(seen, slots, baseline)
baseline = false
if len(events) > 0 {
a.post(func() { a.announce(events, bell) })
a.post(func() { a.announce(events, a.bell) })
}
}
select {