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:
+30
-2
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user