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:
@@ -2,3 +2,4 @@
|
||||
/dist/
|
||||
config.json
|
||||
*.test
|
||||
/dev/data/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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§ion=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§ion=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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user