First start: create the config file and open the Config view

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
This commit is contained in:
2026-10-21 15:16:08 +02:00
co-authored by Claude Opus 5.5
parent 12d572757e
commit b259e22458
15 changed files with 193 additions and 13 deletions
+30 -2
View File
@@ -3,6 +3,7 @@ package ui
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/gdamore/tcell/v2"
"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/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
@@ -203,6 +205,11 @@ func New(cfg *config.Config) *App {
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
a.app.SetInputCapture(a.handleKey)
a.switchTo(0)
if !cfg.AnyConfigured() {
// First start (or nothing set up): go where that is done.
a.switchTo(a.viewIndex("Config"))
a.Notify(levelInfo, "Welcome! Enter the addresses and API keys of your services, then Save")
}
a.renderServices()
return a
}
@@ -314,7 +321,11 @@ func (a *App) SetService(name, version string, err error) {
a.state[name] = serviceState{known: true, version: version, err: err}
// Log connection changes only, so background polling does not flood the log.
switch {
case err != nil && (!prev.known || prev.err == nil):
case notConfigured(err):
if !prev.known || !notConfigured(prev.err) {
a.logs.add(levelInfo, name+" is not configured; set it up in the Config view (F6)")
}
case err != nil && (!prev.known || prev.err == nil || notConfigured(prev.err)):
a.logs.add(levelError, name+" unreachable: "+err.Error())
case err == nil && (!prev.known || prev.err != nil):
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
@@ -354,7 +365,7 @@ func (a *App) renderServices() {
}
st := a.state[n]
switch {
case !st.known:
case !st.known, notConfigured(st.err):
s += "[gray]○ " + short + "[-] "
case st.err != nil:
s += "[red]● " + short + "[-] "
@@ -373,6 +384,8 @@ func (a *App) serviceSummary() string {
switch {
case !st.known:
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
case notConfigured(st.err):
fmt.Fprintf(&b, "[gray]○ %s: not configured (Config view, F6)[-]\n", n)
case st.err != nil:
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
case st.version != "":
@@ -384,6 +397,21 @@ func (a *App) serviceSummary() string {
return b.String()
}
// viewIndex returns the position of the view with the given name.
func (a *App) viewIndex(name string) int {
for i, v := range a.views {
if v.name == name {
return i
}
}
return 0
}
// notConfigured reports whether err says a service is not set up.
func notConfigured(err error) bool {
return errors.Is(err, arr.ErrNotConfigured) || errors.Is(err, sabnzbd.ErrNotConfigured)
}
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
func (a *App) post(f func()) {
a.app.QueueUpdateDraw(f)
+35
View File
@@ -1,11 +1,15 @@
package ui
import (
"errors"
"strings"
"testing"
"github.com/gdamore/tcell/v2"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
)
func newTestApp(t *testing.T) *App {
@@ -46,3 +50,34 @@ func TestQDoesNotQuit(t *testing.T) {
t.Error("q must be passed on as an ordinary key, not taken as quit")
}
}
func TestStartsWithoutServices(t *testing.T) {
// The config a first start creates: nothing set up.
a := New(&config.Config{UI: config.UI{RefreshSeconds: 3}})
if a.views[a.active].name != "Config" {
t.Errorf("first start opens %q, want the Config view", a.views[a.active].name)
}
// The clients do not try to connect without an address.
_, err := a.radarr.SystemStatus(t.Context())
if !errors.Is(err, arr.ErrNotConfigured) {
t.Fatalf("radarr without address: %v", err)
}
if _, err := a.sab.Version(t.Context()); !errors.Is(err, sabnzbd.ErrNotConfigured) {
t.Fatalf("sabnzbd without address: %v", err)
}
// Not configured is grey, not red, and logged once as information.
a.SetService("Radarr", "", err)
a.SetService("Radarr", "", err)
if txt := a.services.GetText(false); !strings.Contains(txt, "○ Radarr") {
t.Errorf("indicator: %q", txt)
}
log := a.logs.root.GetText(true)
if strings.Count(log, "Radarr is not configured") != 1 || strings.Contains(log, "unreachable") {
t.Errorf("log: %q", log)
}
if !strings.Contains(a.serviceSummary(), "Radarr: not configured") {
t.Errorf("help summary: %q", a.serviceSummary())
}
}
+4 -2
View File
@@ -294,12 +294,14 @@ func (v *calendarView) reload() {
if gen != v.gen {
return
}
// A service that is not set up just contributes nothing.
usable := func(err error) bool { return err == nil || notConfigured(err) }
for _, err := range []error{movieErr, tvErr} {
if err != nil {
if !usable(err) {
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
}
}
if movieErr == nil || tvErr == nil { // show what we got
if usable(movieErr) || usable(tvErr) { // show what we got
v.entries, v.loaded = entries, true
}
v.render()
+17 -2
View File
@@ -35,6 +35,7 @@ type downloadsView struct {
sab *sabnzbd.Client
sources []arrSource
interval time.Duration
sabUnset bool // SABnzbd is not set up: its lists say so instead of "empty"
root *tview.Flex
summary *tview.TextView
@@ -210,6 +211,12 @@ func (v *downloadsView) refresh(ctx context.Context) {
v.app.post(func() {
v.app.SetService("SABnzbd", "", qerr)
if unset := notConfigured(qerr); unset != v.sabUnset {
v.sabUnset = unset
v.sabQueue, v.slots, v.done = sabnzbd.Queue{}, nil, nil
v.renderQueue()
v.renderHistory()
}
if qerr == nil {
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
v.renderQueue()
@@ -230,6 +237,14 @@ func (v *downloadsView) refresh(ctx context.Context) {
})
}
// sabPlaceholder is what an empty SABnzbd list says.
func (v *downloadsView) sabPlaceholder(empty string) string {
if v.sabUnset {
return "SABnzbd is not configured (Config view, F6)"
}
return empty
}
func (v *downloadsView) rebuildArrRows() {
v.arrRows = v.arrRows[:0]
for _, s := range v.sources {
@@ -331,7 +346,7 @@ func (v *downloadsView) renderQueue() {
}
}
fill(v.queue, 0, rows)
restoreSelection(v.queue, len(rows), selRow, "Queue is empty")
restoreSelection(v.queue, len(rows), selRow, v.sabPlaceholder("Queue is empty"))
}
func (v *downloadsView) renderArr() {
@@ -414,7 +429,7 @@ func (v *downloadsView) renderHistory() {
}
}
fill(v.history, 0, rows)
restoreSelection(v.history, len(rows), selRow, "No history")
restoreSelection(v.history, len(rows), selRow, v.sabPlaceholder("No history"))
}
// act runs an action off the UI goroutine, reports the outcome and refreshes.
+7 -2
View File
@@ -363,8 +363,11 @@ func (v *libraryView) reloadPane(p *libPane) {
defer v.loadSeriesFiles(false)
}
if err != nil {
// Keep showing the last good list; say why it is stale.
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
// Keep showing the last good list; say why it is stale. A service
// that is not set up says so in its list instead.
if !notConfigured(err) {
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
}
} else {
p.all, p.loaded = items, true
}
@@ -418,6 +421,8 @@ func (v *libraryView) renderPane(p *libPane) {
}
placeholder := "Nothing matches the filter"
switch {
case notConfigured(p.err):
placeholder = p.kind.service() + " is not configured (Config view, F6)"
case p.err != nil && !p.loaded:
placeholder = "Could not load, press r to retry"
case !p.loaded:
+4
View File
@@ -382,6 +382,9 @@ func (v *settingsView) describe() {
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 {
@@ -478,6 +481,7 @@ func (v *settingsView) save() {
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")
}