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:
@@ -48,6 +48,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNotConfigured is returned without a request while the client has no
|
||||
// address: the service is not set up.
|
||||
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||
|
||||
// APIError is returned for non-2xx responses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
@@ -65,6 +69,9 @@ func (e *APIError) Error() string {
|
||||
// the response is decoded into out (if non-nil).
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
base, apiKey := c.settings()
|
||||
if base == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
|
||||
@@ -135,6 +135,9 @@ func (e *notImageError) Error() string {
|
||||
// their URL base in image paths, so it must not be added twice.
|
||||
func (c *Client) resolve(ref string) (string, error) {
|
||||
baseURL, _ := c.settings()
|
||||
if baseURL == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -18,6 +18,24 @@ type Service struct {
|
||||
APIKey string `json:"apikey"`
|
||||
}
|
||||
|
||||
// Configured reports whether the service is set up at all (address or key
|
||||
// given). Validate checks that a configured service has both.
|
||||
func (s Service) Configured() bool { return s.URL != "" || s.APIKey != "" }
|
||||
|
||||
// AnyConfigured reports whether at least one service is set up.
|
||||
func (c *Config) AnyConfigured() bool {
|
||||
return c.Radarr.Configured() || c.Sonarr.Configured() || c.SABnzbd.Configured()
|
||||
}
|
||||
|
||||
// Create writes an empty configuration to path, creating its directory
|
||||
// (private, like the file). Every service is not set up yet.
|
||||
func Create(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("creating config directory: %w", err)
|
||||
}
|
||||
return Save(path, &Config{})
|
||||
}
|
||||
|
||||
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
|
||||
type Arr struct {
|
||||
Service
|
||||
@@ -246,7 +264,9 @@ func (c *Config) applyDefaults() {
|
||||
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
|
||||
}
|
||||
|
||||
// Validate checks that every service has a usable URL and API key.
|
||||
// Validate checks that every service that is set up has a usable URL and API
|
||||
// key. A service with neither is not set up, which is allowed: the program
|
||||
// starts without it and says so, and the Config view can fill it in.
|
||||
func (c *Config) Validate() error {
|
||||
var errs []error
|
||||
for _, s := range []struct {
|
||||
@@ -257,6 +277,9 @@ func (c *Config) Validate() error {
|
||||
{"sonarr", c.Sonarr.Service},
|
||||
{"sabnzbd", c.SABnzbd},
|
||||
} {
|
||||
if !s.svc.Configured() {
|
||||
continue // not set up (yet); the program says so where it is needed
|
||||
}
|
||||
if s.svc.APIKey == "" {
|
||||
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestParseErrors(t *testing.T) {
|
||||
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
|
||||
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
|
||||
"invalid json": {`{`, "parsing config"},
|
||||
"key only": {`{"radarr":{"apikey":"x"}}`, "radarr: url is required"},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -200,3 +201,35 @@ func TestEffectiveValidates(t *testing.T) {
|
||||
t.Error("an invalid config must not become effective")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesMayBeLeftOut(t *testing.T) {
|
||||
cfg, err := Parse([]byte(`{"radarr":{"url":"http://r","apikey":"k"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a config with only Radarr must load: %v", err)
|
||||
}
|
||||
if !cfg.Radarr.Configured() || cfg.Sonarr.Configured() || cfg.SABnzbd.Configured() || !cfg.AnyConfigured() {
|
||||
t.Errorf("configured: radarr %v sonarr %v sab %v", cfg.Radarr.Configured(), cfg.Sonarr.Configured(), cfg.SABnzbd.Configured())
|
||||
}
|
||||
empty, err := Parse([]byte(`{}`))
|
||||
if err != nil || empty.AnyConfigured() {
|
||||
t.Errorf("an empty config must load with nothing set up: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "new", "dir", "config.json")
|
||||
if err := Create(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("file: %v %v", info, err)
|
||||
}
|
||||
if dir, _ := os.Stat(filepath.Dir(path)); dir.Mode().Perm() != 0o700 {
|
||||
t.Errorf("directory mode %v", dir.Mode().Perm())
|
||||
}
|
||||
cfg, warnings, err := Load(path)
|
||||
if err != nil || len(warnings) > 0 || cfg.AnyConfigured() {
|
||||
t.Errorf("the new file must load cleanly: %v %v", warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNotConfigured is returned without a request while the client has no
|
||||
// address: the service is not set up.
|
||||
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||
|
||||
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
|
||||
type Number float64
|
||||
|
||||
@@ -172,6 +176,9 @@ func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||
c.mu.RLock()
|
||||
baseURL, apiKey := c.baseURL, c.apiKey
|
||||
c.mu.RUnlock()
|
||||
if baseURL == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
params.Set("apikey", apiKey)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
|
||||
|
||||
+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