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)
|
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
||||||
- **License:** MIT
|
- **License:** MIT
|
||||||
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
||||||
- **Status:** M6 done
|
- **Status:** M7 done
|
||||||
|
|
||||||
## 1. Goals
|
## 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] **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] **M5** — Radarr/Sonarr queues merged into the Downloads view.
|
||||||
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
- [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).
|
- [ ] **M8+** — v0.2 features (library view, series drill-down).
|
||||||
|
|
||||||
## 10. Open Questions
|
## 10. Open Questions
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"git.stinnesbeck.com/nils/arr-tui/internal/ui"
|
"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() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "arr-tui:", err)
|
fmt.Fprintln(os.Stderr, "arr-tui:", err)
|
||||||
@@ -19,8 +22,14 @@ func main() {
|
|||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
path := flag.String("config", "", "path to config.json (default: $XDG_CONFIG_HOME/arr-tui/config.json)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println("arr-tui", version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if *path == "" {
|
if *path == "" {
|
||||||
p, err := config.DefaultPath()
|
p, err := config.DefaultPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+72
-3
@@ -34,8 +34,21 @@ type App struct {
|
|||||||
|
|
||||||
message string
|
message string
|
||||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
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.
|
// overlay is a page stacked above the main views; focus is restored on close.
|
||||||
type overlay struct {
|
type overlay struct {
|
||||||
page string
|
page string
|
||||||
@@ -49,6 +62,9 @@ func New(cfg *config.Config) *App {
|
|||||||
tabs: tview.NewTextView().SetDynamicColors(true),
|
tabs: tview.NewTextView().SetDynamicColors(true),
|
||||||
pages: tview.NewPages(),
|
pages: tview.NewPages(),
|
||||||
status: tview.NewTextView().SetDynamicColors(true),
|
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)
|
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).
|
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.pages, 0, 1, true).
|
||||||
AddItem(a.status, 1, 0, false)
|
AddItem(a.status, 1, 0, false)
|
||||||
|
|
||||||
@@ -78,9 +96,56 @@ func New(cfg *config.Config) *App {
|
|||||||
a.app.SetInputCapture(a.handleKey)
|
a.app.SetInputCapture(a.handleKey)
|
||||||
a.switchTo(0)
|
a.switchTo(0)
|
||||||
a.renderStatus()
|
a.renderStatus()
|
||||||
|
a.renderServices()
|
||||||
return a
|
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
|
// SetMessage shows a message in the status bar. UI goroutine only; use
|
||||||
// post from other goroutines.
|
// post from other goroutines.
|
||||||
func (a *App) SetMessage(text string) {
|
func (a *App) SetMessage(text string) {
|
||||||
@@ -99,7 +164,7 @@ func (a *App) Run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderStatus() {
|
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 {
|
if len(a.overlays) > 0 {
|
||||||
hints = "[gray]Esc back · Ctrl+C quit"
|
hints = "[gray]Esc back · Ctrl+C quit"
|
||||||
}
|
}
|
||||||
@@ -180,8 +245,12 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
a.app.Stop()
|
a.app.Stop()
|
||||||
return nil
|
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 len(a.overlays) == 0 {
|
||||||
|
if ev.Key() == tcell.KeyF9 {
|
||||||
|
a.showHelp()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
||||||
a.switchTo(i)
|
a.switchTo(i)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
|
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
var errs []string
|
var errs []string
|
||||||
|
v.app.SetService("SABnzbd", "", qerr)
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
errs = append(errs, "SABnzbd: "+qerr.Error())
|
errs = append(errs, "SABnzbd: "+qerr.Error())
|
||||||
} else {
|
} else {
|
||||||
@@ -205,6 +206,7 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
v.renderHistory()
|
v.renderHistory()
|
||||||
}
|
}
|
||||||
for i, s := range v.sources {
|
for i, s := range v.sources {
|
||||||
|
v.app.SetService(s.name, "", arrs[i].err)
|
||||||
if arrs[i].err != nil {
|
if arrs[i].err != nil {
|
||||||
errs = append(errs, s.name+": "+arrs[i].err.Error())
|
errs = append(errs, s.name+": "+arrs[i].err.Error())
|
||||||
continue
|
continue
|
||||||
@@ -387,7 +389,7 @@ func (v *downloadsView) renderHistory() {
|
|||||||
case h.Failed():
|
case h.Failed():
|
||||||
status = "[red]" + status + "[-]"
|
status = "[red]" + status + "[-]"
|
||||||
if h.FailMessage != "" {
|
if h.FailMessage != "" {
|
||||||
status += " [gray](" + tview.Escape(truncate(h.FailMessage, 28)) + ")[-]"
|
status += " [gray](" + tview.Escape(truncate(h.FailMessage, 60)) + ")[-]"
|
||||||
}
|
}
|
||||||
case h.Status == "Completed":
|
case h.Status == "Completed":
|
||||||
status = "[green]" + status + "[-]"
|
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.mu.Unlock()
|
||||||
|
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
|
v.app.SetService(v.be.name(), status.Version, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
|
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 {
|
if gen != v.gen {
|
||||||
return // superseded by a newer search
|
return // superseded by a newer search
|
||||||
}
|
}
|
||||||
|
v.app.SetService(v.be.name(), "", err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user