diff --git a/.gitignore b/.gitignore index 7ceaba4..98bfa30 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /dist/ config.json *.test +/dev/data/ diff --git a/SPECS.md b/SPECS.md index 899fa5d..6317592 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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. diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..69dab5d --- /dev/null +++ b/dev/README.md @@ -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. diff --git a/dev/compose.yaml b/dev/compose.yaml new file mode 100644 index 0000000..8e7fbed --- /dev/null +++ b/dev/compose.yaml @@ -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 diff --git a/dev/seed/sabnzbd.ini b/dev/seed/sabnzbd.ini new file mode 100644 index 0000000..5a7bbc5 --- /dev/null +++ b/dev/seed/sabnzbd.ini @@ -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 diff --git a/dev/up.sh b/dev/up.sh new file mode 100755 index 0000000..e539846 --- /dev/null +++ b/dev/up.sh @@ -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 < 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 +} diff --git a/internal/sabnzbd/sabnzbd_test.go b/internal/sabnzbd/sabnzbd_test.go new file mode 100644 index 0000000..d868ec4 --- /dev/null +++ b/internal/sabnzbd/sabnzbd_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 09ae22e..60f229a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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() diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go new file mode 100644 index 0000000..7116a16 --- /dev/null +++ b/internal/ui/downloads.go @@ -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) + }) +} diff --git a/internal/ui/format.go b/internal/ui/format.go index 992ac71..f8ef7c8 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -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]) + "…" +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index e5954f4..5342e5f 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -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) + } +}