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
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## Unreleased
- **First start without a config file:** arr-tui creates the file (private, with its directory)
and opens the Config view to enter the services, instead of refusing to start. Services may be
left out: one without address and key is not used, its indicator stays grey and its lists say
"not configured" instead of showing errors.
## v0.3.0
New:
+5 -3
View File
@@ -56,9 +56,11 @@ or build from a checkout (`make build`), or `make dist` for cross-compiled binar
## Configure
Copy `config.example.json` to `$XDG_CONFIG_HOME/arr-tui/config.json` (usually
`~/.config/arr-tui/config.json`), or pass `--config path`. API keys are in each app under
Settings → General.
Just start `arr-tui`: on the first start it creates `$XDG_CONFIG_HOME/arr-tui/config.json`
(usually `~/.config/arr-tui/config.json`, or the file given with `--config path`) and opens the
Config view (F6), where you enter the address and API key of each service and save. API keys are in
each app under Settings → General. A service left empty is not used (e.g. no SABnzbd), its indicator
stays grey. You can also write the file yourself, starting from `config.example.json`:
```json
{
+1 -1
View File
@@ -69,7 +69,7 @@ Notes:
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
### 4.2 v0.2 (done, M8)
+9
View File
@@ -2,8 +2,10 @@
package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
@@ -38,6 +40,13 @@ func run() error {
*path = p
}
// No config yet (first start): create an empty one, the Config view fills it in.
if _, err := os.Stat(*path); errors.Is(err, fs.ErrNotExist) {
if err := config.Create(*path); err != nil {
return err
}
}
cfg, warnings, err := config.Load(*path)
if err != nil {
return err
+7
View File
@@ -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()
+3
View File
@@ -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
+24 -1
View File
@@ -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))
}
+33
View File
@@ -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)
}
}
+7
View File
@@ -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
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.
+6 -1
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.
// 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")
}