Add help screen, connection indicators, README and release builds (M7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+72
-3
@@ -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
|
||||
|
||||
@@ -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 + "[-]"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user