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
+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