From 3f6e595719292dc143e185b3b838e71e06217a81 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 19:11:07 +0200 Subject: [PATCH] Add help screen, connection indicators, README and release builds (M7) Co-Authored-By: Claude Sonnet 5.5 --- Makefile | 26 ++++++++++++++ README.md | 67 +++++++++++++++++++++++++++++++++++ SPECS.md | 4 +-- cmd/arr-tui/main.go | 9 +++++ internal/ui/app.go | 75 ++++++++++++++++++++++++++++++++++++++-- internal/ui/downloads.go | 4 ++- internal/ui/help.go | 48 +++++++++++++++++++++++++ internal/ui/search.go | 4 +-- 8 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 Makefile create mode 100644 README.md create mode 100644 internal/ui/help.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e05c7fb --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 + +.PHONY: build test dist clean + +build: + go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui + +test: + go vet ./... + go test ./... + +# Cross-compiled binaries and checksums in dist/. +dist: + rm -rf dist && mkdir dist + @for t in $(TARGETS); do \ + os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \ + echo "building $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ + -o dist/arr-tui-$$os-$$arch$$ext ./cmd/arr-tui || exit 1; \ + done + cd dist && sha256sum arr-tui-* > SHA256SUMS + +clean: + rm -rf dist arr-tui diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca79e15 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# arr-tui + +A terminal UI to search for movies and series with [Radarr](https://radarr.video) and +[Sonarr](https://sonarr.tv), pick releases by hand, and follow the downloads in +[SABnzbd](https://sabnzbd.org). + +- **Movies / Series**: search as you type, add titles with a quality profile and root folder, + optionally start the automatic search. +- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), + sort and filter them, and grab one. Series: pick a season or a single episode first. +- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, + retry failed downloads, remove from the arr queue and blocklist. +- Connection indicators for all three services in the top right; `?` shows every key. + +Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are +shown for grabbing, torrents are hidden. + +## Install + +```sh +go install git.stinnesbeck.com/nils/arr-tui/cmd/arr-tui@latest +``` + +or build from a checkout (`make build`), or `make dist` for cross-compiled binaries in `dist/`. + +## 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. + +```json +{ + "radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" }, + "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" }, + "sabnzbd": { "url": "http://localhost:8080", "apikey": "..." }, + "ui": { "refresh_seconds": 3 } +} +``` + +`default_*` are optional preselections in the add dialog. URL and key can also come from the +environment: `ARR_TUI_{RADARR,SONARR,SABNZBD}_{URL,APIKEY}`, which takes precedence over the file. +Keep the file private (`chmod 600`); it holds your API keys. + +## Keys + +| Key | Action | +|---|---| +| `F1`–`F4`, `Alt+1`–`4`, `Tab` | switch view | +| `?` / `F9` | help | +| `/` | focus search box | +| `Enter`, `a` | add selected title | +| `i` | interactive release search | +| `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected | +| `p`, `d`, `t`, `b`, `h`, `r` (downloads) | pause, delete, retry, blocklist, next panel, refresh | +| `Esc` | back / close dialog | +| `q`, `Ctrl+C` | quit | + +## Development + +`dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer; +see [dev/README.md](dev/README.md). `make test` runs vet and the unit tests. The design and +milestone history are in [SPECS.md](SPECS.md). + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/SPECS.md b/SPECS.md index 5cde07b..4df61f8 100644 --- a/SPECS.md +++ b/SPECS.md @@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M6 done +- **Status:** M7 done ## 1. Goals @@ -193,7 +193,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. - [x] **M5** — Radarr/Sonarr queues merged into the Downloads view. - [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. -- [ ] **M7** — Polish: help screen, error handling, README, release builds. +- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [ ] **M8+** — v0.2 features (library view, series drill-down). ## 10. Open Questions diff --git a/cmd/arr-tui/main.go b/cmd/arr-tui/main.go index 41c6c2e..ca0cda0 100644 --- a/cmd/arr-tui/main.go +++ b/cmd/arr-tui/main.go @@ -10,6 +10,9 @@ import ( "git.stinnesbeck.com/nils/arr-tui/internal/ui" ) +// version is set at build time: -ldflags "-X main.version=v0.1.0". +var version = "dev" + func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "arr-tui:", err) @@ -19,8 +22,14 @@ func main() { func run() error { path := flag.String("config", "", "path to config.json (default: $XDG_CONFIG_HOME/arr-tui/config.json)") + showVersion := flag.Bool("version", false, "print the version and exit") flag.Parse() + if *showVersion { + fmt.Println("arr-tui", version) + return nil + } + if *path == "" { p, err := config.DefaultPath() if err != nil { diff --git a/internal/ui/app.go b/internal/ui/app.go index bc30a52..bd34441 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -34,8 +34,21 @@ type App struct { message string overlays []overlay // dialogs and full-screen views stacked above the pages + + services *tview.TextView + state map[string]serviceState // keyed by service name } +// serviceState is the last known connection state of a backend service. +type serviceState struct { + known bool + version string + err error +} + +// serviceNames fixes the order of the connection indicators. +var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"} + // overlay is a page stacked above the main views; focus is restored on close. type overlay struct { page string @@ -49,6 +62,9 @@ func New(cfg *config.Config) *App { tabs: tview.NewTextView().SetDynamicColors(true), pages: tview.NewPages(), status: tview.NewTextView().SetDynamicColors(true), + + services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight), + state: map[string]serviceState{}, } radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) @@ -70,7 +86,9 @@ func New(cfg *config.Config) *App { } layout := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(a.tabs, 1, 0, false). + AddItem(tview.NewFlex(). + AddItem(a.tabs, 0, 1, false). + AddItem(a.services, 34, 0, false), 1, 0, false). AddItem(a.pages, 0, 1, true). AddItem(a.status, 1, 0, false) @@ -78,9 +96,56 @@ func New(cfg *config.Config) *App { a.app.SetInputCapture(a.handleKey) a.switchTo(0) a.renderStatus() + a.renderServices() return a } +// SetService records the outcome of a request to a service and updates the +// connection indicators. UI goroutine only. A nil err means reachable. +func (a *App) SetService(name, version string, err error) { + prev := a.state[name] + if err == nil && version == "" { + version = prev.version + } + a.state[name] = serviceState{known: true, version: version, err: err} + a.renderServices() +} + +func (a *App) renderServices() { + var s string + for _, n := range serviceNames { + st := a.state[n] + switch { + case !st.known: + s += "[gray]○ " + n + "[-] " + case st.err != nil: + s += "[red]● " + n + "[-] " + default: + s += "[green]●[-] " + n + " " + } + } + a.services.SetText(s) +} + +// serviceSummary describes every service for the help screen. +func (a *App) serviceSummary() string { + var b strings.Builder + for _, n := range serviceNames { + st := a.state[n] + switch { + case !st.known: + fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n) + case st.err != nil: + fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error())) + case st.version != "": + fmt.Fprintf(&b, "[green]●[-] %s %s: connected\n", n, tview.Escape(st.version)) + default: + fmt.Fprintf(&b, "[green]●[-] %s: connected\n", n) + } + } + return b.String() +} + // SetMessage shows a message in the status bar. UI goroutine only; use // post from other goroutines. func (a *App) SetMessage(text string) { @@ -99,7 +164,7 @@ func (a *App) Run() error { } func (a *App) renderStatus() { - hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search" + hints := "[gray]?/F9 help · q quit · Tab/F1-F4 views · / search" if len(a.overlays) > 0 { hints = "[gray]Esc back · Ctrl+C quit" } @@ -180,8 +245,12 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { a.app.Stop() return nil } - // View switching that also works while typing in a search box. Not while a dialog is open. + // Shortcuts that also work while typing in a search box. Not while a dialog is open. if len(a.overlays) == 0 { + if ev.Key() == tcell.KeyF9 { + a.showHelp() + return nil + } if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) { a.switchTo(i) return nil diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go index e5896df..29efb8f 100644 --- a/internal/ui/downloads.go +++ b/internal/ui/downloads.go @@ -194,6 +194,7 @@ func (v *downloadsView) refresh(ctx context.Context) { v.app.post(func() { var errs []string + v.app.SetService("SABnzbd", "", qerr) if qerr != nil { errs = append(errs, "SABnzbd: "+qerr.Error()) } else { @@ -205,6 +206,7 @@ func (v *downloadsView) refresh(ctx context.Context) { v.renderHistory() } for i, s := range v.sources { + v.app.SetService(s.name, "", arrs[i].err) if arrs[i].err != nil { errs = append(errs, s.name+": "+arrs[i].err.Error()) continue @@ -387,7 +389,7 @@ func (v *downloadsView) renderHistory() { case h.Failed(): status = "[red]" + status + "[-]" if h.FailMessage != "" { - status += " [gray](" + tview.Escape(truncate(h.FailMessage, 28)) + ")[-]" + status += " [gray](" + tview.Escape(truncate(h.FailMessage, 60)) + ")[-]" } case h.Status == "Completed": status = "[green]" + status + "[-]" diff --git a/internal/ui/help.go b/internal/ui/help.go new file mode 100644 index 0000000..362b3b2 --- /dev/null +++ b/internal/ui/help.go @@ -0,0 +1,48 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const helpText = `[yellow]Global[-] + F1-F4, Alt+1-4, Tab switch view + ?, F9 this help (F9 works while typing) + q, Ctrl+C quit + Esc close dialog / go back + +[yellow]Movies and Series[-] + / focus the search box + Enter, a add the selected title + i interactive release search (library titles) + +[yellow]Release list[-] + Enter, g grab the selected release + s cycle sort (best, size, age) + f hide rejected releases + +[yellow]Downloads[-] + h next panel + p pause / resume the SABnzbd queue + d delete the selected job or entry + b remove from arr queue + blocklist + t retry a failed download (history) + r refresh now + +[yellow]Connections[-] +` + +// showHelp opens the key reference together with the connection state. +func (a *App) showHelp() { + tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true) + tv.SetBorder(true).SetTitle(" Help — Esc to close ") + tv.SetText(helpText + a.serviceSummary()) + tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) { + a.closeModal() + return nil + } + return ev + }) + a.openModal(tv, 76, 34) +} diff --git a/internal/ui/search.go b/internal/ui/search.go index 94806eb..0e6d7a3 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -129,11 +129,10 @@ func (v *searchView) loadOptions() { v.mu.Unlock() v.app.post(func() { + v.app.SetService(v.be.name(), status.Version, err) if err != nil { v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error()))) - return } - v.app.SetMessage(fmt.Sprintf("[green]%s %s connected", v.be.name(), tview.Escape(status.Version))) }) } @@ -204,6 +203,7 @@ func (v *searchView) run(ctx context.Context, gen int, term string) { if gen != v.gen { return // superseded by a newer search } + v.app.SetService(v.be.name(), "", err) if err != nil { v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error())) return