Add SABnzbd client and Downloads view (M4)

SABnzbd client (queue, history, pause/resume, delete, retry) with lenient
number decoding and API-key-safe errors. Downloads view polls while
visible, shows queue and history, and supports pause, delete (with
confirmation) and retry. Add dev/ with pinned Radarr/Sonarr/SABnzbd
containers wired together for manual testing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:58:32 +02:00
co-authored by Claude Sonnet 5.5
parent 2dd7c17737
commit aeeb2c5f50
12 changed files with 1261 additions and 9 deletions
+1
View File
@@ -2,3 +2,4 @@
/dist/
config.json
*.test
/dev/data/
+7 -5
View File
@@ -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:** M3 done
- **Status:** M4 done
## 1. Goals
@@ -106,8 +106,10 @@ Keybindings (initial proposal):
| `Enter` | Search / open details / confirm |
| `a` | Add selected result |
| `m` / `s` | Switch to movies / series search |
| `p` | Pause/resume SABnzbd |
| `d` | Delete selected download (with confirm) |
| `p` | Pause/resume SABnzbd (Downloads view) |
| `d` | Delete selected download or history entry (with confirm) |
| `t` | Retry the selected failed download (history) |
| `h` | Downloads view: switch between queue and history |
| `i` | Interactive release search for the selected item |
| `g` | Grab the selected release |
| `r` | Refresh |
@@ -177,7 +179,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
- Unit tests for each client using `httptest.Server` with recorded JSON fixtures.
- Config parsing / validation tests.
- UI logic (formatting, state) kept out of tview widgets so it can be tested without a terminal.
- Manual testing against real Radarr/Sonarr/SABnzbd instances (ideally a docker-compose file for local dev).
- Manual testing against real Radarr/Sonarr/SABnzbd instances (`dev/up.sh` starts pinned Radarr/Sonarr/SABnzbd containers, see `dev/README.md`).
## 9. Milestones
@@ -185,7 +187,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
- [x] **M1** — Radarr client: system status, lookup, quality profiles, root folders, add movie.
- [x] **M2** — Movie search + add flow in the UI.
- [x] **M3** — Sonarr client and series search + add flow.
- [ ] **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.
- [ ] **M5** — Radarr/Sonarr queues merged into the Downloads view.
- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
- [ ] **M7** — Polish: help screen, error handling, README, release builds.
+27
View File
@@ -0,0 +1,27 @@
# Local test instances
Radarr, Sonarr and SABnzbd in containers (same versions as the author's setup), pre-wired together:
fixed dev API keys, root folders `/media/movies` and `/media/tv`, and SABnzbd registered as the
download client in both (categories `radarr` / `sonarr`).
```sh
dev/up.sh # start + configure (idempotent); needs docker/podman compose
go run ./cmd/arr-tui --config dev/config.json
docker compose -f dev/compose.yaml down # stop; state lives in dev/data (gitignored)
```
| Service | URL | Port |
|---|---|---|
| Radarr | http://127.0.0.1:17878 | 17878 |
| Sonarr | http://127.0.0.1:18989 | 18989 |
| SABnzbd | http://127.0.0.1:18080 | 18080 |
No indexers or Usenet servers are configured, so nothing actually downloads. To get something into
the SABnzbd queue, upload any NZB:
```sh
curl -F name=@some.nzb -F cat=radarr \
"http://127.0.0.1:18080/api?mode=addfile&output=json&apikey=devsabnzbdapikey0000000000000000"
```
Metadata lookups (search) need internet access, since Radarr/Sonarr query their cloud metadata services.
+45
View File
@@ -0,0 +1,45 @@
# Local test instances for arr-tui. Not for real use: fixed dev API keys, bound to localhost.
#
# docker compose -f dev/compose.yaml up -d
# ARR_TUI_* env or dev/config.json (see dev/README.md) points the TUI at them.
#
# Ports are offset (+10000) so they do not clash with real instances on the same machine.
x-common: &common
restart: unless-stopped
# Rootless podman maps container root to the host user, so bind mounts stay writable.
user: "0:0"
environment: &env
TZ: Europe/Berlin
services:
radarr:
<<: *common
image: ghcr.io/home-operations/radarr:6.3.0.10514
environment:
<<: *env
RADARR__AUTH__APIKEY: devradarrapikey00000000000000000
ports: ["127.0.0.1:17878:7878"]
volumes:
- ./data/radarr:/config
- ./data/media:/media
- ./data/downloads:/downloads
sonarr:
<<: *common
image: ghcr.io/home-operations/sonarr:4.0.20
environment:
<<: *env
SONARR__AUTH__APIKEY: devsonarrapikey00000000000000000
ports: ["127.0.0.1:18989:8989"]
volumes:
- ./data/sonarr:/config
- ./data/media:/media
- ./data/downloads:/downloads
sabnzbd:
<<: *common
image: ghcr.io/home-operations/sabnzbd:5.1.0
ports: ["127.0.0.1:18080:8080"]
volumes:
- ./data/sabnzbd:/config
- ./data/downloads:/downloads
+331
View File
@@ -0,0 +1,331 @@
__encoding__ = utf-8
__version__ = 19
[misc]
api_key = devsabnzbdapikey0000000000000000
nzb_key = devsabnzbdnzbkey0000000000000000
port = 8080
host = 0.0.0.0
config_conversion_version = 5
helpful_warnings = 1
queue_complete = ""
queue_complete_pers = 0
bandwidth_perc = 100
refresh_rate = 0
interface_settings = ""
queue_limit = 20
config_lock = 0
fixed_ports = 1
notified_new_skin = 0
direct_unpack_tested = 0
sorters_converted = 1
check_new_rel = 1
auto_browser = 0
language = en
enable_https_verification = 1
https_port = ""
username = ""
password = ""
bandwidth_max = ""
cache_limit = 1G
web_dir = Glitter
web_color = Auto
https_cert = server.cert
https_key = server.key
https_chain = ""
enable_https = 0
inet_exposure = 0
socks5_proxy_url = ""
permissions = ""
download_dir = Downloads/incomplete
download_free = 500M
complete_dir = Downloads/complete
complete_free = ""
fulldisk_autoresume = 0
script_dir = ""
nzb_backup_dir = ""
admin_dir = admin
backup_dir = ""
dirscan_dir = ""
dirscan_speed = 5
password_file = ""
log_dir = logs
max_art_tries = 3
top_only = 0
sfv_check = 1
script_can_fail = 0
enable_recursive = 1
flat_unpack = 0
par_option = ""
pre_check = 0
nice = ""
win_process_prio = 3
ionice = ""
fail_hopeless_jobs = 1
fast_fail = 1
auto_disconnect = 1
pre_script = None
end_queue_script = None
no_dupes = 0
no_series_dupes = 0
no_smart_dupes = 0
dupes_propercheck = 1
pause_on_pwrar = 1
ignore_samples = 0
deobfuscate_final_filenames = 1
auto_sort = ""
direct_unpack = 0
propagation_delay = 0
folder_rename = 1
replace_spaces = 0
replace_underscores = 0
replace_dots = 0
safe_postproc = 1
pause_on_post_processing = 0
enable_all_par = 0
sanitize_safe = 0
cleanup_list = ,
unwanted_extensions = ,
action_on_unwanted_extensions = 0
unwanted_extensions_mode = 0
new_nzb_on_failure = 0
history_retention = ""
history_retention_option = all
history_retention_number = 0
quota_size = ""
quota_day = ""
quota_resume = 0
quota_period = m
enable_tv_sorting = 0
tv_sort_string = ""
tv_categories = tv,
enable_movie_sorting = 0
movie_sort_string = ""
movie_sort_extra = -cd%1
movie_categories = movies,
enable_date_sorting = 0
date_sort_string = ""
date_categories = tv,
schedlines = ,
rss_rate = 60
ampm = 0
start_paused = 0
preserve_paused_state = 0
enable_par_cleanup = 1
process_unpacked_par2 = 1
enable_unrar = 1
enable_7zip = 1
enable_filejoin = 1
enable_tsjoin = 1
enable_tar = 1
overwrite_files = 0
ignore_unrar_dates = 0
backup_for_duplicates = 0
wait_for_dfolder = 0
rss_filenames = 0
api_logging = 1
html_login = 1
disable_archive = 0
warn_dupl_jobs = 0
keep_awake = 1
tray_icon = 1
allow_incomplete_nzb = 0
enable_broadcast = 1
ipv6_hosting = 0
ipv6_staging = 0
api_warnings = 1
no_penalties = 0
x_frame_options = 1
allow_old_ssl_tls = 0
enable_season_sorting = 1
verify_xff_header = 1
direct_write = 1
rss_odd_titles = nzbindex.nl/, nzbindex.com/, nzbclub.com/
quick_check_ext_ignore = nfo, sfv, srr
req_completion_rate = 100.2
selftest_host = self-test.sabnzbd.org
movie_rename_limit = 100M
episode_rename_limit = 20M
size_limit = 0
direct_unpack_threads = 3
history_limit = 10
wait_ext_drive = 5
max_foldername_length = 246
nomedia_marker = ""
ipv6_servers = 1
url_base = ""
local_ranges = ,
max_url_retries = 10
downloader_sleep_time = 10
receive_threads = 2
assembler_max_queue_size = 12
switchinterval = 0.005
ssdp_broadcast_interval = 15
ext_rename_ignore = ,
unrar_parameters = ""
outgoing_nntp_ip = ""
email_server = ""
email_to = ,
email_from = ""
email_account = ""
email_pwd = ""
email_endjob = 0
email_full = 0
email_dir = ""
email_rss = 0
email_cats = *,
[logging]
log_level = 1
max_log_size = 5242880
log_backups = 5
[ncenter]
ncenter_enable = 0
ncenter_cats = *,
ncenter_prio_startup = 0
ncenter_prio_download = 0
ncenter_prio_pause_resume = 0
ncenter_prio_pp = 0
ncenter_prio_complete = 1
ncenter_prio_failed = 1
ncenter_prio_disk_full = 1
ncenter_prio_quota = 1
ncenter_prio_new_login = 0
ncenter_prio_warning = 0
ncenter_prio_error = 0
ncenter_prio_queue_done = 0
ncenter_prio_other = 1
[acenter]
acenter_enable = 0
acenter_cats = *,
acenter_prio_startup = 0
acenter_prio_download = 0
acenter_prio_pause_resume = 0
acenter_prio_pp = 0
acenter_prio_complete = 1
acenter_prio_failed = 1
acenter_prio_disk_full = 1
acenter_prio_quota = 1
acenter_prio_new_login = 0
acenter_prio_warning = 0
acenter_prio_error = 0
acenter_prio_queue_done = 0
acenter_prio_other = 1
[ntfosd]
ntfosd_enable = 1
ntfosd_cats = *,
ntfosd_prio_startup = 0
ntfosd_prio_download = 0
ntfosd_prio_pause_resume = 0
ntfosd_prio_pp = 0
ntfosd_prio_complete = 1
ntfosd_prio_failed = 1
ntfosd_prio_disk_full = 1
ntfosd_prio_quota = 1
ntfosd_prio_new_login = 0
ntfosd_prio_warning = 0
ntfosd_prio_error = 0
ntfosd_prio_queue_done = 0
ntfosd_prio_other = 1
[prowl]
prowl_enable = 0
prowl_cats = *,
prowl_apikey = ""
prowl_prio_startup = -3
prowl_prio_download = -3
prowl_prio_pause_resume = -3
prowl_prio_pp = -3
prowl_prio_complete = 0
prowl_prio_failed = 1
prowl_prio_disk_full = 1
prowl_prio_quota = 0
prowl_prio_new_login = -3
prowl_prio_warning = -3
prowl_prio_error = -3
prowl_prio_queue_done = -3
prowl_prio_other = 0
[pushover]
pushover_token = ""
pushover_userkey = ""
pushover_device = ""
pushover_emergency_expire = 3600
pushover_emergency_retry = 60
pushover_enable = 0
pushover_cats = *,
pushover_prio_startup = -3
pushover_prio_download = -2
pushover_prio_pause_resume = -2
pushover_prio_pp = -3
pushover_prio_complete = -1
pushover_prio_failed = -1
pushover_prio_disk_full = 1
pushover_prio_quota = -1
pushover_prio_new_login = -3
pushover_prio_warning = 1
pushover_prio_error = 1
pushover_prio_queue_done = -3
pushover_prio_other = -1
[pushbullet]
pushbullet_enable = 0
pushbullet_cats = *,
pushbullet_apikey = ""
pushbullet_device = ""
pushbullet_prio_startup = 0
pushbullet_prio_download = 0
pushbullet_prio_pause_resume = 0
pushbullet_prio_pp = 0
pushbullet_prio_complete = 1
pushbullet_prio_failed = 1
pushbullet_prio_disk_full = 1
pushbullet_prio_quota = 1
pushbullet_prio_new_login = 0
pushbullet_prio_warning = 0
pushbullet_prio_error = 0
pushbullet_prio_queue_done = 0
pushbullet_prio_other = 1
[apprise]
apprise_enable = 0
apprise_cats = *,
apprise_urls = ""
apprise_target_startup = ""
apprise_target_startup_enable = 0
apprise_target_download = ""
apprise_target_download_enable = 0
apprise_target_pause_resume = ""
apprise_target_pause_resume_enable = 0
apprise_target_pp = ""
apprise_target_pp_enable = 0
apprise_target_complete = ""
apprise_target_complete_enable = 1
apprise_target_failed = ""
apprise_target_failed_enable = 1
apprise_target_disk_full = ""
apprise_target_disk_full_enable = 0
apprise_target_quota = ""
apprise_target_quota_enable = 1
apprise_target_new_login = ""
apprise_target_new_login_enable = 1
apprise_target_warning = ""
apprise_target_warning_enable = 0
apprise_target_error = ""
apprise_target_error_enable = 0
apprise_target_queue_done = ""
apprise_target_queue_done_enable = 0
apprise_target_other = ""
apprise_target_other_enable = 1
[nscript]
nscript_enable = 0
nscript_cats = *,
nscript_script = ""
nscript_parameters = ""
nscript_prio_startup = 0
nscript_prio_download = 0
nscript_prio_pause_resume = 0
nscript_prio_pp = 0
nscript_prio_complete = 1
nscript_prio_failed = 1
nscript_prio_disk_full = 1
nscript_prio_quota = 1
nscript_prio_new_login = 0
nscript_prio_warning = 0
nscript_prio_error = 0
nscript_prio_queue_done = 0
nscript_prio_other = 1
Executable
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Start the local Radarr/Sonarr/SABnzbd test instances and configure them (idempotent).
set -euo pipefail
cd "$(dirname "$0")"
RADARR_KEY=devradarrapikey00000000000000000
SONARR_KEY=devsonarrapikey00000000000000000
SAB_KEY=devsabnzbdapikey0000000000000000
RADARR=http://127.0.0.1:17878
SONARR=http://127.0.0.1:18989
SAB=http://127.0.0.1:18080
mkdir -p data/{radarr,sonarr,sabnzbd,downloads,media/movies,media/tv}
[ -f data/sabnzbd/sabnzbd.ini ] || cp seed/sabnzbd.ini data/sabnzbd/sabnzbd.ini
docker compose -f compose.yaml up -d
wait_for() { # name url [header]
for _ in $(seq 60); do
curl -sf -m3 ${3:+-H "$3"} "$2" >/dev/null && return 0
sleep 2
done
echo "timed out waiting for $1" >&2; exit 1
}
wait_for radarr "$RADARR/api/v3/system/status" "X-Api-Key: $RADARR_KEY"
wait_for sonarr "$SONARR/api/v3/system/status" "X-Api-Key: $SONARR_KEY"
wait_for sabnzbd "$SAB/api?mode=version&output=json&apikey=$SAB_KEY"
# Radarr/Sonarr reach SABnzbd as "sabnzbd"; its hostname check must allow that.
curl -sf "$SAB/api?mode=set_config&section=misc&keyword=host_whitelist&value=sabnzbd,localhost&output=json&apikey=$SAB_KEY" >/dev/null
for cat in radarr sonarr; do
curl -sf "$SAB/api?mode=set_config&section=categories&name=$cat&dir=$cat&output=json&apikey=$SAB_KEY" >/dev/null
done
post_once() { # base key path jq-check body : POST body unless GET path already matches
local base=$1 key=$2 path=$3 check=$4 body=$5
if curl -sf -H "X-Api-Key: $key" "$base/api/v3/$path" | grep -q "$check"; then return 0; fi
curl -sf -H "X-Api-Key: $key" -H 'Content-Type: application/json' -d "$body" "$base/api/v3/$path" >/dev/null
}
post_once "$RADARR" "$RADARR_KEY" rootfolder '"/media/movies' '{"path":"/media/movies"}'
post_once "$SONARR" "$SONARR_KEY" rootfolder '"/media/tv' '{"path":"/media/tv"}'
client() { # category-field category
printf '{"enable":true,"protocol":"usenet","priority":1,"name":"SABnzbd","implementation":"Sabnzbd","configContract":"SabnzbdSettings","tags":[],"fields":[{"name":"host","value":"sabnzbd"},{"name":"port","value":8080},{"name":"useSsl","value":false},{"name":"apiKey","value":"%s"},{"name":"%s","value":"%s"}]}' "$SAB_KEY" "$1" "$2"
}
post_once "$RADARR" "$RADARR_KEY" downloadclient '"SABnzbd"' "$(client movieCategory radarr)"
post_once "$SONARR" "$SONARR_KEY" downloadclient '"SABnzbd"' "$(client tvCategory sonarr)"
cat <<MSG
Dev instances are ready:
Radarr $RADARR key $RADARR_KEY
Sonarr $SONARR key $SONARR_KEY
SABnzbd $SAB key $SAB_KEY
MSG
+216
View File
@@ -0,0 +1,216 @@
// Package sabnzbd is a client for the SABnzbd HTTP API.
package sabnzbd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client talks to a SABnzbd instance.
type Client struct {
baseURL string
apiKey string
http *http.Client
}
// NewClient returns a client for the instance at baseURL.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
type Number float64
// UnmarshalJSON accepts numbers, numeric strings, empty strings and null.
func (n *Number) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
*n = 0
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("parsing number %q: %w", s, err)
}
*n = Number(f)
return nil
}
// Slot is a job in the download queue.
type Slot struct {
ID string `json:"nzo_id"`
Name string `json:"filename"`
Category string `json:"cat"`
Status string `json:"status"` // Downloading, Queued, Paused, Fetching, ...
Priority string `json:"priority"`
Size string `json:"size"`
SizeLeft string `json:"sizeleft"`
MB Number `json:"mb"`
MBLeft Number `json:"mbleft"`
Percentage Number `json:"percentage"`
TimeLeft string `json:"timeleft"`
}
// Queue is the current download queue.
type Queue struct {
Status string `json:"status"` // Downloading, Idle, Paused
Paused bool `json:"paused"`
KBPerSec Number `json:"kbpersec"`
SpeedLimit Number `json:"speedlimit_abs"` // bytes/s, 0 = unlimited
Size string `json:"size"`
SizeLeft string `json:"sizeleft"`
TimeLeft string `json:"timeleft"`
TotalSlots int `json:"noofslots_total"`
Slots []Slot `json:"slots"`
}
// HistorySlot is a finished (or post-processing) job.
type HistorySlot struct {
ID string `json:"nzo_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"` // Completed, Failed, Extracting, ...
Size string `json:"size"`
FailMessage string `json:"fail_message"`
Completed int64 `json:"completed"` // unix time
}
// Failed reports whether the job failed.
func (h HistorySlot) Failed() bool { return h.Status == "Failed" }
// Version returns the SABnzbd version string.
func (c *Client) Version(ctx context.Context) (string, error) {
var r struct {
Version string `json:"version"`
}
err := c.call(ctx, url.Values{"mode": {"version"}}, &r)
return r.Version, err
}
// Queue fetches the download queue.
func (c *Client) Queue(ctx context.Context) (Queue, error) {
var r struct {
Queue Queue `json:"queue"`
}
err := c.call(ctx, url.Values{"mode": {"queue"}}, &r)
return r.Queue, err
}
// History fetches the most recent limit history entries.
func (c *Client) History(ctx context.Context, limit int) ([]HistorySlot, error) {
var r struct {
History struct {
Slots []HistorySlot `json:"slots"`
} `json:"history"`
}
err := c.call(ctx, url.Values{"mode": {"history"}, "limit": {strconv.Itoa(limit)}}, &r)
return r.History.Slots, err
}
// Pause pauses the whole queue.
func (c *Client) Pause(ctx context.Context) error {
return c.call(ctx, url.Values{"mode": {"pause"}}, nil)
}
// Resume resumes the whole queue.
func (c *Client) Resume(ctx context.Context) error {
return c.call(ctx, url.Values{"mode": {"resume"}}, nil)
}
// DeleteJob removes a job from the queue, optionally deleting its files.
func (c *Client) DeleteJob(ctx context.Context, id string, deleteFiles bool) error {
return c.call(ctx, url.Values{
"mode": {"queue"}, "name": {"delete"}, "value": {id}, "del_files": {boolParam(deleteFiles)},
}, nil)
}
// DeleteHistory removes a history entry, optionally deleting its files.
func (c *Client) DeleteHistory(ctx context.Context, id string, deleteFiles bool) error {
return c.call(ctx, url.Values{
"mode": {"history"}, "name": {"delete"}, "value": {id}, "del_files": {boolParam(deleteFiles)},
}, nil)
}
// Retry re-queues a failed job from the history.
func (c *Client) Retry(ctx context.Context, id string) error {
return c.call(ctx, url.Values{"mode": {"retry"}, "value": {id}}, nil)
}
func boolParam(b bool) string {
if b {
return "1"
}
return "0"
}
// call performs an API request. SABnzbd reports failures with HTTP 200 and
// {"status": false, "error": "..."}, which is turned into an error here.
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
params.Set("output", "json")
params.Set("apikey", c.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api?"+params.Encode(), nil)
if err != nil {
return stripURL(err)
}
resp, err := c.http.Do(req)
if err != nil {
return stripURL(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return stripURL(err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
}
var failure struct {
Status any `json:"status"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &failure); err != nil {
// e.g. the plain-text "Access denied - Hostname verification failed"
return fmt.Errorf("unexpected response: %s", snippet(body))
}
if failure.Error != "" {
return errors.New(failure.Error)
}
if ok, isBool := failure.Status.(bool); isBool && !ok {
return errors.New("request failed")
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
return nil
}
func snippet(b []byte) string {
return strings.TrimSpace(string(b[:min(len(b), 200)]))
}
// stripURL drops the request URL from transport errors: it contains the API key.
func stripURL(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return fmt.Errorf("%s: %w", ue.Op, ue.Err)
}
return err
}
+124
View File
@@ -0,0 +1,124 @@
package sabnzbd
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const queueJSON = `{"queue":{"status":"Downloading","paused":false,"kbpersec":"1536.50","speedlimit_abs":"0",
"size":"4.0 GB","sizeleft":"2.0 GB","timeleft":"0:21:10","noofslots_total":2,
"slots":[
{"nzo_id":"SABnzbd_nzo_a","filename":"Movie.2020.1080p","cat":"radarr","status":"Downloading","priority":"Normal",
"size":"3 GB","sizeleft":"1 GB","mb":"3072.00","mbleft":"1024.00","percentage":"67","timeleft":"0:11:10"},
{"nzo_id":"SABnzbd_nzo_b","filename":"Show.S01E01","cat":"sonarr","status":"Queued","mb":1000,"mbleft":1000,"percentage":0}
]}}`
func newTestClient(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return NewClient(srv.URL, "secret")
}
func TestQueue(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("mode") != "queue" || q.Get("apikey") != "secret" || q.Get("output") != "json" {
t.Errorf("unexpected request %s", r.URL)
}
w.Write([]byte(queueJSON))
})
q, err := c.Queue(context.Background())
if err != nil {
t.Fatal(err)
}
if q.Paused || q.KBPerSec != 1536.5 || q.TotalSlots != 2 || len(q.Slots) != 2 {
t.Fatalf("queue = %+v", q)
}
s := q.Slots[0]
if s.ID != "SABnzbd_nzo_a" || s.Percentage != 67 || s.MB != 3072 || s.Category != "radarr" {
t.Errorf("slot = %+v", s)
}
if q.Slots[1].Percentage != 0 || q.Slots[1].MB != 1000 {
t.Errorf("numeric (non-string) values not decoded: %+v", q.Slots[1])
}
}
func TestHistory(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("mode") != "history" || r.URL.Query().Get("limit") != "10" {
t.Errorf("unexpected request %s", r.URL)
}
w.Write([]byte(`{"history":{"slots":[
{"nzo_id":"a","name":"Good","status":"Completed","size":"1 GB","completed":1700000000},
{"nzo_id":"b","name":"Bad","status":"Failed","fail_message":"Aborted, cannot be completed"}]}}`))
})
h, err := c.History(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(h) != 2 || h[0].Failed() || !h[1].Failed() || h[1].FailMessage == "" {
t.Fatalf("history = %+v", h)
}
}
func TestActions(t *testing.T) {
var got []string
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
got = append(got, strings.Join([]string{q.Get("mode"), q.Get("name"), q.Get("value"), q.Get("del_files")}, "|"))
w.Write([]byte(`{"status":true}`))
})
ctx := context.Background()
for _, err := range []error{
c.Pause(ctx), c.Resume(ctx),
c.DeleteJob(ctx, "id1", true), c.DeleteHistory(ctx, "id2", false), c.Retry(ctx, "id3"),
} {
if err != nil {
t.Fatal(err)
}
}
want := []string{"pause|||", "resume|||", "queue|delete|id1|1", "history|delete|id2|0", "retry||id3|"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("requests = %v, want %v", got, want)
}
}
func TestErrors(t *testing.T) {
tests := map[string]struct {
status int
body string
want string
}{
"bad key": {200, `{"status":false,"error":"API Key Incorrect"}`, "API Key Incorrect"},
"host check": {200, `Access denied - Hostname verification failed`, "Hostname verification failed"},
"http error": {500, `boom`, "HTTP 500: boom"},
"status false": {200, `{"status":false}`, "request failed"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
w.Write([]byte(tc.body))
})
_, err := c.Version(context.Background())
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want containing %q", err, tc.want)
}
})
}
}
func TestTransportErrorHidesAPIKey(t *testing.T) {
c := NewClient("http://127.0.0.1:1", "topsecretkey")
_, err := c.Version(context.Background())
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "topsecretkey") {
t.Errorf("error leaks API key: %v", err)
}
}
+32 -4
View File
@@ -3,12 +3,14 @@ package ui
import (
"fmt"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
@@ -18,6 +20,8 @@ type view struct {
key rune
name string
body tview.Primitive
// Optional hooks, called when the view becomes visible / hidden.
onShow, onHide func()
}
// App is the top-level arr-tui application.
@@ -43,11 +47,15 @@ func New(cfg *config.Config) *App {
status: tview.NewTextView().SetDynamicColors(true),
}
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.views = []view{
{'1', "Movies", newSearchView(a, radarrBackend{radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)}, cfg.Radarr).root},
{'2', "Series", newSearchView(a, sonarrBackend{sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)}, cfg.Sonarr).root},
{'3', "Downloads", placeholder("Downloads (M4/M5)")},
{'4', "Library", placeholder("Library (v0.2)")},
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root},
{key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate},
{key: '4', name: "Library", body: placeholder("Library (v0.2)")},
}
for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0)
@@ -115,6 +123,20 @@ func (a *App) closeModal() {
}
}
// confirm asks a yes/no question; yes runs only if the user picks Delete/Yes.
func (a *App) confirm(text string, yes func()) {
m := tview.NewModal().
SetText(text).
AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe
SetDoneFunc(func(i int, _ string) {
a.closeModal()
if i == 1 {
yes()
}
})
a.openModal(m, 60, 9)
}
// typing reports whether keystrokes belong to a text field or dialog and must
// not trigger global shortcuts.
func (a *App) typing() bool {
@@ -178,6 +200,12 @@ func (a *App) viewShortcut(ev *tcell.EventKey) int {
}
func (a *App) switchTo(i int) {
if hide := a.views[a.active].onHide; hide != nil && i != a.active {
hide()
}
if show := a.views[i].onShow; show != nil && i != a.active {
show()
}
a.active = i
a.pages.SwitchToPage(a.views[i].name)
a.renderTabs()
+327
View File
@@ -0,0 +1,327 @@
package ui
import (
"context"
"fmt"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
)
const historyLimit = 25
// downloadsView shows the SABnzbd queue and history and polls while visible.
type downloadsView struct {
app *App
sab *sabnzbd.Client
interval time.Duration
root *tview.Flex
summary *tview.TextView
queue *tview.Table
history *tview.Table
// UI-goroutine state.
slots []sabnzbd.Slot
done []sabnzbd.HistorySlot
paused bool
cancel context.CancelFunc // stops the poller
}
func newDownloadsView(app *App, sab *sabnzbd.Client, interval time.Duration) *downloadsView {
v := &downloadsView{
app: app,
sab: sab,
interval: interval,
summary: tview.NewTextView().SetDynamicColors(true),
queue: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
history: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
}
v.queue.SetBorder(true).SetTitle(" SABnzbd queue ")
v.history.SetBorder(true).SetTitle(" SABnzbd history ")
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.summary, 1, 0, false).
AddItem(v.queue, 0, 3, true).
AddItem(v.history, 0, 2, false)
v.queue.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case 'p':
v.togglePause()
case 'd':
v.deleteSelected()
case 'r':
v.refreshNow()
case 'h':
v.app.app.SetFocus(v.history)
default:
return ev
}
return nil
})
v.history.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case 't':
v.retrySelected()
case 'd':
v.deleteSelected()
case 'r':
v.refreshNow()
case 'h':
v.app.app.SetFocus(v.queue)
case 'p':
v.togglePause()
default:
return ev
}
return nil
})
v.renderQueue()
v.renderHistory()
return v
}
// activate starts polling; called when the view becomes visible.
func (v *downloadsView) activate() {
v.deactivate()
ctx, cancel := context.WithCancel(context.Background())
v.cancel = cancel
go v.poll(ctx)
}
// deactivate stops polling; called when the view is hidden.
func (v *downloadsView) deactivate() {
if v.cancel != nil {
v.cancel()
v.cancel = nil
}
}
func (v *downloadsView) poll(ctx context.Context) {
v.refresh(ctx)
t := time.NewTicker(v.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
v.refresh(ctx)
}
}
}
func (v *downloadsView) refreshNow() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
go func() {
defer cancel()
v.refresh(ctx)
}()
}
// refresh fetches queue and history off the UI goroutine and applies them.
func (v *downloadsView) refresh(ctx context.Context) {
q, err := v.sab.Queue(ctx)
if err != nil {
if ctx.Err() == nil {
v.app.post(func() { v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error())) })
}
return
}
h, herr := v.sab.History(ctx, historyLimit)
if ctx.Err() != nil {
return
}
v.app.post(func() {
v.slots, v.paused = q.Slots, q.Paused
v.renderSummary(q)
v.renderQueue()
if herr == nil {
v.done = h
v.renderHistory()
}
})
}
func (v *downloadsView) renderSummary(q sabnzbd.Queue) {
var s string
switch {
case q.Paused:
s = "[yellow]⏸ Paused[-]"
case float64(q.KBPerSec) > 0:
s = fmt.Sprintf("[green]↓ %s[-]", formatSpeed(float64(q.KBPerSec)))
default:
s = "[gray]Idle[-]"
}
s += fmt.Sprintf(" · %d job%s", q.TotalSlots, plural(q.TotalSlots))
if q.TotalSlots > 0 {
s += fmt.Sprintf(" · %s left", tview.Escape(q.SizeLeft))
if !q.Paused && float64(q.KBPerSec) > 0 {
s += " · ETA " + tview.Escape(q.TimeLeft)
}
}
v.summary.SetText(s)
}
func header(t *tview.Table, cols ...string) {
for i, c := range cols {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
if i == 0 {
cell.SetExpansion(1)
}
t.SetCell(0, i, cell)
}
}
func (v *downloadsView) renderQueue() {
sel := ""
if r, _ := v.queue.GetSelection(); r >= 1 && r <= len(v.slots) {
sel = v.slots[r-1].ID
}
v.queue.Clear()
header(v.queue, "Name", "Category", "Status", "Size", "Progress", "ETA")
row := 1
for i, s := range v.slots {
cells := []string{
tview.Escape(s.Name), tview.Escape(s.Category), tview.Escape(s.Status), tview.Escape(s.Size),
progressBar(float64(s.Percentage), 10), tview.Escape(s.TimeLeft),
}
for c, text := range cells {
cell := tview.NewTableCell(text)
if c == 0 {
cell.SetExpansion(1)
}
v.queue.SetCell(row, c, cell)
}
if s.ID == sel {
v.queue.Select(i+1, 0)
}
row++
}
if len(v.slots) == 0 {
v.queue.SetCell(1, 0, tview.NewTableCell("[gray]Queue is empty").SetSelectable(false))
v.queue.Select(0, 0)
} else if sel == "" {
v.queue.Select(1, 0)
}
}
func (v *downloadsView) renderHistory() {
sel := ""
if r, _ := v.history.GetSelection(); r >= 1 && r <= len(v.done) {
sel = v.done[r-1].ID
}
v.history.Clear()
header(v.history, "Name", "Category", "Status", "Size", "Finished")
now := time.Now()
for i, h := range v.done {
status := tview.Escape(h.Status)
switch {
case h.Failed():
status = "[red]" + status + "[-]"
if h.FailMessage != "" {
status += " [gray](" + tview.Escape(truncate(h.FailMessage, 28)) + ")[-]"
}
case h.Status == "Completed":
status = "[green]" + status + "[-]"
}
cells := []string{
tview.Escape(h.Name), tview.Escape(h.Category), status, tview.Escape(h.Size), formatAge(h.Completed, now),
}
for c, text := range cells {
cell := tview.NewTableCell(text)
if c == 0 {
cell.SetExpansion(1)
}
v.history.SetCell(i+1, c, cell)
}
if h.ID == sel {
v.history.Select(i+1, 0)
}
}
if len(v.done) == 0 {
v.history.SetCell(1, 0, tview.NewTableCell("[gray]No history").SetSelectable(false))
v.history.Select(0, 0)
} else if sel == "" {
v.history.Select(1, 0)
}
}
// act runs a SABnzbd action off the UI goroutine, reports the outcome and refreshes.
func (v *downloadsView) act(doing, done string, f func(ctx context.Context) error) {
v.app.SetMessage(doing)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := f(ctx)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]" + done)
})
if err == nil {
v.refresh(ctx)
}
}()
}
func (v *downloadsView) togglePause() {
if v.paused {
v.act("Resuming…", "Queue resumed", v.sab.Resume)
} else {
v.act("Pausing…", "Queue paused", v.sab.Pause)
}
}
func (v *downloadsView) deleteSelected() {
if v.app.app.GetFocus() == v.history {
r, _ := v.history.GetSelection()
if r < 1 || r > len(v.done) {
return
}
h := v.done[r-1]
v.app.confirm("Remove "+tview.Escape(h.Name)+" from history?", func() {
v.act("Removing…", "Removed "+tview.Escape(h.Name), func(ctx context.Context) error {
return v.sab.DeleteHistory(ctx, h.ID, false)
})
})
return
}
r, _ := v.queue.GetSelection()
if r < 1 || r > len(v.slots) {
return
}
s := v.slots[r-1]
v.app.confirm("Delete "+tview.Escape(s.Name)+" from the queue?", func() {
v.act("Deleting…", "Deleted "+tview.Escape(s.Name), func(ctx context.Context) error {
return v.sab.DeleteJob(ctx, s.ID, false)
})
})
}
func (v *downloadsView) retrySelected() {
r, _ := v.history.GetSelection()
if r < 1 || r > len(v.done) {
return
}
h := v.done[r-1]
if !h.Failed() {
v.app.SetMessage("[yellow]Only failed downloads can be retried")
return
}
v.act("Retrying…", "Retrying "+tview.Escape(h.Name), func(ctx context.Context) error {
return v.sab.Retry(ctx, h.ID)
})
}
+45
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/rivo/tview"
@@ -149,3 +150,47 @@ func plural(n int) string {
}
return "s"
}
// formatSpeed renders a transfer rate given in KiB/s.
func formatSpeed(kbPerSec float64) string {
switch {
case kbPerSec >= 1024:
return fmt.Sprintf("%.1f MB/s", kbPerSec/1024)
case kbPerSec >= 1:
return fmt.Sprintf("%.0f KB/s", kbPerSec)
}
return "0 KB/s"
}
// progressBar renders pct (0-100) as a bar of the given width, followed by the percentage.
func progressBar(pct float64, width int) string {
pct = min(max(pct, 0), 100)
filled := int(pct / 100 * float64(width))
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + fmt.Sprintf(" %3.0f%%", pct)
}
// formatAge renders a unix timestamp relative to now ("5m ago", "3d ago").
func formatAge(unix int64, now time.Time) string {
if unix <= 0 {
return ""
}
d := now.Sub(time.Unix(unix, 0))
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
// truncate shortens s to at most n runes, ending in "…" if it was cut.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}
+50
View File
@@ -3,6 +3,7 @@ package ui
import (
"strings"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
@@ -86,3 +87,52 @@ func TestResultKeysAndWithID(t *testing.T) {
t.Errorf("series key = %q", r.key())
}
}
func TestFormatSpeed(t *testing.T) {
tests := map[float64]string{0: "0 KB/s", 0.4: "0 KB/s", 512: "512 KB/s", 1536: "1.5 MB/s"}
for in, want := range tests {
if got := formatSpeed(in); got != want {
t.Errorf("formatSpeed(%v) = %q, want %q", in, got, want)
}
}
}
func TestProgressBar(t *testing.T) {
if got := progressBar(50, 10); got != "█████░░░░░ 50%" {
t.Errorf("got %q", got)
}
if got := progressBar(150, 4); got != "████ 100%" {
t.Errorf("clamp high: %q", got)
}
if got := progressBar(-5, 4); got != "░░░░ 0%" {
t.Errorf("clamp low: %q", got)
}
}
func TestFormatAge(t *testing.T) {
now := time.Unix(1_000_000, 0)
tests := map[int64]string{
0: "",
now.Unix() - 10: "just now",
now.Unix() - 5*60: "5m ago",
now.Unix() - 3*3600: "3h ago",
now.Unix() - 3*24*3600: "3d ago",
}
for in, want := range tests {
if got := formatAge(in, now); got != want {
t.Errorf("formatAge(%d) = %q, want %q", in, got, want)
}
}
}
func TestTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Errorf("got %q", got)
}
if got := truncate("Aborted, cannot be completed", 10); got != "Aborted, …" {
t.Errorf("got %q", got)
}
if got := truncate("äöüäöü", 4); got != "äöü…" {
t.Errorf("rune handling: %q", got)
}
}