Show Radarr and Sonarr queues in the Downloads view (M5)
Add queue listing and removal/blocklisting to the shared arr client. The Downloads view now fetches SABnzbd and both arr queues in parallel, shows status, progress and import problems, flags items needing attention in the summary, and supports removing or blocklisting items. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and
|
|||||||
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
||||||
- **License:** MIT
|
- **License:** MIT
|
||||||
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
||||||
- **Status:** M4 done
|
- **Status:** M5 done
|
||||||
|
|
||||||
## 1. Goals
|
## 1. Goals
|
||||||
|
|
||||||
@@ -107,9 +107,10 @@ Keybindings (initial proposal):
|
|||||||
| `a` | Add selected result |
|
| `a` | Add selected result |
|
||||||
| `m` / `s` | Switch to movies / series search |
|
| `m` / `s` | Switch to movies / series search |
|
||||||
| `p` | Pause/resume SABnzbd (Downloads view) |
|
| `p` | Pause/resume SABnzbd (Downloads view) |
|
||||||
| `d` | Delete selected download or history entry (with confirm) |
|
| `d` | Delete selected download, history entry or Radarr/Sonarr queue item (with confirm) |
|
||||||
| `t` | Retry the selected failed download (history) |
|
| `t` | Retry the selected failed download (history) |
|
||||||
| `h` | Downloads view: switch between queue and history |
|
| `h` | Downloads view: cycle focus between SABnzbd queue, Radarr/Sonarr queue and history |
|
||||||
|
| `b` | Downloads view (Radarr/Sonarr queue): blocklist the release and search for another |
|
||||||
| `i` | Interactive release search for the selected item |
|
| `i` | Interactive release search for the selected item |
|
||||||
| `g` | Grab the selected release |
|
| `g` | Grab the selected release |
|
||||||
| `r` | Refresh |
|
| `r` | Refresh |
|
||||||
@@ -188,7 +189,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
|
|||||||
- [x] **M2** — Movie search + add flow in the UI.
|
- [x] **M2** — Movie search + add flow in the UI.
|
||||||
- [x] **M3** — Sonarr client and series search + add flow.
|
- [x] **M3** — Sonarr client and series search + add flow.
|
||||||
- [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
|
- [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
|
||||||
- [ ] **M5** — Radarr/Sonarr queues merged into the Downloads view.
|
- [x] **M5** — Radarr/Sonarr queues merged into the Downloads view.
|
||||||
- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
||||||
- [ ] **M7** — Polish: help screen, error handling, README, release builds.
|
- [ ] **M7** — Polish: help screen, error handling, README, release builds.
|
||||||
- [ ] **M8+** — v0.2 features (library view, series drill-down).
|
- [ ] **M8+** — v0.2 features (library view, series drill-down).
|
||||||
|
|||||||
@@ -25,3 +25,13 @@ curl -F name=@some.nzb -F cat=radarr \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Metadata lookups (search) need internet access, since Radarr/Sonarr query their cloud metadata services.
|
Metadata lookups (search) need internet access, since Radarr/Sonarr query their cloud metadata services.
|
||||||
|
|
||||||
|
To get an item into the Radarr (or Sonarr) queue as well, add a movie to Radarr, serve an NZB from the
|
||||||
|
host and push it as a release. Containers reach the host as `host.containers.internal`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m http.server 19191 --bind 0.0.0.0 & # directory containing some.nzb
|
||||||
|
curl -H "X-Api-Key: devradarrapikey00000000000000000" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"title":"The.Matrix.1999.1080p.BluRay.x264-DEV","downloadUrl":"http://host.containers.internal:19191/some.nzb","protocol":"usenet","publishDate":"2026-01-01T00:00:00Z"}' \
|
||||||
|
http://127.0.0.1:17878/api/v3/release/push
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package arr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusMessage is a warning or error attached to a queue item, e.g. why an import is blocked.
|
||||||
|
type StatusMessage struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Messages []string `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueItem is an entry of the Radarr/Sonarr download queue.
|
||||||
|
type QueueItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"` // release title
|
||||||
|
Status string `json:"status"`
|
||||||
|
TrackedDownloadStatus string `json:"trackedDownloadStatus"` // ok, warning, error
|
||||||
|
TrackedDownloadState string `json:"trackedDownloadState"` // downloading, importPending, importBlocked, ...
|
||||||
|
StatusMessages []StatusMessage `json:"statusMessages"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
Size float64 `json:"size"`
|
||||||
|
SizeLeft float64 `json:"sizeleft"`
|
||||||
|
TimeLeft string `json:"timeleft"`
|
||||||
|
DownloadClient string `json:"downloadClient"`
|
||||||
|
DownloadID string `json:"downloadId"` // the download client's id, e.g. SABnzbd's nzo_id
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
|
||||||
|
// Present depending on the include* parameters of the request.
|
||||||
|
Movie *QueueMovie `json:"movie,omitempty"`
|
||||||
|
Series *QueueSeries `json:"series,omitempty"`
|
||||||
|
Episode *QueueEpisode `json:"episode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueMovie is the movie a queue item belongs to.
|
||||||
|
type QueueMovie struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueSeries is the series a queue item belongs to.
|
||||||
|
type QueueSeries struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueEpisode is the episode a queue item belongs to.
|
||||||
|
type QueueEpisode struct {
|
||||||
|
SeasonNumber int `json:"seasonNumber"`
|
||||||
|
EpisodeNumber int `json:"episodeNumber"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns a readable name: "Movie (2020)", "Show S01E02", or the release title.
|
||||||
|
func (q QueueItem) Name() string {
|
||||||
|
switch {
|
||||||
|
case q.Movie != nil && q.Movie.Title != "":
|
||||||
|
if q.Movie.Year > 0 {
|
||||||
|
return fmt.Sprintf("%s (%d)", q.Movie.Title, q.Movie.Year)
|
||||||
|
}
|
||||||
|
return q.Movie.Title
|
||||||
|
case q.Series != nil && q.Series.Title != "":
|
||||||
|
if q.Episode != nil {
|
||||||
|
return fmt.Sprintf("%s S%02dE%02d", q.Series.Title, q.Episode.SeasonNumber, q.Episode.EpisodeNumber)
|
||||||
|
}
|
||||||
|
return q.Series.Title
|
||||||
|
}
|
||||||
|
return q.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress returns the download progress in percent (0-100).
|
||||||
|
func (q QueueItem) Progress() float64 {
|
||||||
|
if q.Size <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return min(max((q.Size-q.SizeLeft)/q.Size*100, 0), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsAttention reports whether the item has a warning or error.
|
||||||
|
func (q QueueItem) NeedsAttention() bool {
|
||||||
|
return q.TrackedDownloadStatus == "warning" || q.TrackedDownloadStatus == "error" ||
|
||||||
|
q.TrackedDownloadState == "importBlocked" || q.TrackedDownloadState == "failed" ||
|
||||||
|
q.TrackedDownloadState == "failedPending"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem returns the item's warnings and errors as one line ("" if none).
|
||||||
|
func (q QueueItem) Problem() string {
|
||||||
|
var parts []string
|
||||||
|
if q.ErrorMessage != "" {
|
||||||
|
parts = append(parts, q.ErrorMessage)
|
||||||
|
}
|
||||||
|
for _, m := range q.StatusMessages {
|
||||||
|
parts = append(parts, m.Messages...)
|
||||||
|
if len(m.Messages) == 0 && m.Title != "" {
|
||||||
|
parts = append(parts, m.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueWith lists the queue with the given extra query parameters, e.g.
|
||||||
|
// includeMovie=true. It fetches a single page of up to 200 items.
|
||||||
|
func (c *Client) QueueWith(ctx context.Context, include url.Values) ([]QueueItem, error) {
|
||||||
|
q := url.Values{"page": {"1"}, "pageSize": {"200"}}
|
||||||
|
for k, v := range include {
|
||||||
|
q[k] = v
|
||||||
|
}
|
||||||
|
var page struct {
|
||||||
|
Records []QueueItem `json:"records"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx, http.MethodGet, "queue", q, nil, &page); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return page.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveQueueItem removes an item from the queue and the download client.
|
||||||
|
// With blocklist set, the release is blocklisted and a replacement is searched.
|
||||||
|
func (c *Client) RemoveQueueItem(ctx context.Context, id int, blocklist bool) error {
|
||||||
|
q := url.Values{
|
||||||
|
"removeFromClient": {"true"},
|
||||||
|
"blocklist": {strconv.FormatBool(blocklist)},
|
||||||
|
}
|
||||||
|
return c.Do(ctx, http.MethodDelete, "queue/"+strconv.Itoa(id), q, nil, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package arr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const queueJSON = `{"page":1,"pageSize":200,"totalRecords":3,"records":[
|
||||||
|
{"id":1,"title":"Movie.2020.1080p-GRP","status":"downloading","trackedDownloadStatus":"ok","trackedDownloadState":"downloading",
|
||||||
|
"size":1000,"sizeleft":250,"timeleft":"00:05:00","downloadClient":"SABnzbd","downloadId":"SABnzbd_nzo_a",
|
||||||
|
"movie":{"title":"Movie","year":2020}},
|
||||||
|
{"id":2,"title":"Show.S01E02.720p-GRP","status":"completed","trackedDownloadStatus":"warning","trackedDownloadState":"importBlocked",
|
||||||
|
"size":500,"sizeleft":0,
|
||||||
|
"statusMessages":[{"title":"Show.S01E02","messages":["No files found are eligible for import"]}],
|
||||||
|
"series":{"title":"Show"},"episode":{"seasonNumber":1,"episodeNumber":2,"title":"Two"}},
|
||||||
|
{"id":3,"title":"Unmatched.Release","status":"downloading","trackedDownloadStatus":"ok","errorMessage":"Unknown series"}
|
||||||
|
]}`
|
||||||
|
|
||||||
|
func TestQueueWith(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if r.URL.Path != "/api/v3/queue" || q.Get("includeMovie") != "true" || q.Get("pageSize") != "200" {
|
||||||
|
t.Errorf("unexpected request %s", r.URL)
|
||||||
|
}
|
||||||
|
w.Write([]byte(queueJSON))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
items, err := NewClient(srv.URL, "k").QueueWith(context.Background(), map[string][]string{"includeMovie": {"true"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("got %d items", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
m, s, u := items[0], items[1], items[2]
|
||||||
|
if m.Name() != "Movie (2020)" || m.Progress() != 75 || m.DownloadID != "SABnzbd_nzo_a" || m.NeedsAttention() {
|
||||||
|
t.Errorf("movie item: name=%q progress=%v attention=%v", m.Name(), m.Progress(), m.NeedsAttention())
|
||||||
|
}
|
||||||
|
if s.Name() != "Show S01E02" || !s.NeedsAttention() || s.Problem() != "No files found are eligible for import" {
|
||||||
|
t.Errorf("series item: name=%q problem=%q", s.Name(), s.Problem())
|
||||||
|
}
|
||||||
|
if u.Name() != "Unmatched.Release" || u.Problem() != "Unknown series" {
|
||||||
|
t.Errorf("unmatched item: name=%q problem=%q", u.Name(), u.Problem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveQueueItem(t *testing.T) {
|
||||||
|
var got *http.Request
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = r
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := NewClient(srv.URL, "k").RemoveQueueItem(context.Background(), 42, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := got.URL.Query()
|
||||||
|
if got.Method != http.MethodDelete || got.URL.Path != "/api/v3/queue/42" ||
|
||||||
|
q.Get("blocklist") != "true" || q.Get("removeFromClient") != "true" {
|
||||||
|
t.Errorf("request = %s %s", got.Method, got.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressZeroSize(t *testing.T) {
|
||||||
|
if p := (QueueItem{}).Progress(); p != 0 {
|
||||||
|
t.Errorf("progress = %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,3 +140,8 @@ func (c *Client) SearchMovie(ctx context.Context, movieID int) error {
|
|||||||
|
|
||||||
// TMDBTerm builds a lookup term for a TMDb id.
|
// TMDBTerm builds a lookup term for a TMDb id.
|
||||||
func TMDBTerm(tmdbID int) string { return "tmdb:" + strconv.Itoa(tmdbID) }
|
func TMDBTerm(tmdbID int) string { return "tmdb:" + strconv.Itoa(tmdbID) }
|
||||||
|
|
||||||
|
// Queue lists Radarr's download queue, including movie info.
|
||||||
|
func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) {
|
||||||
|
return c.QueueWith(ctx, url.Values{"includeMovie": {"true"}})
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,3 +161,8 @@ func (c *Client) SearchSeries(ctx context.Context, seriesID int) error {
|
|||||||
body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID}
|
body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID}
|
||||||
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
|
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue lists Sonarr's download queue, including series and episode info.
|
||||||
|
func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) {
|
||||||
|
return c.QueueWith(ctx, url.Values{"includeSeries": {"true"}, "includeEpisode": {"true"}})
|
||||||
|
}
|
||||||
|
|||||||
+6
-2
@@ -47,10 +47,14 @@ func New(cfg *config.Config) *App {
|
|||||||
status: tview.NewTextView().SetDynamicColors(true),
|
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)
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||||
|
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
||||||
|
[]arrSource{
|
||||||
|
{name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem},
|
||||||
|
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
||||||
|
},
|
||||||
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||||
a.views = []view{
|
a.views = []view{
|
||||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root},
|
{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: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root},
|
||||||
|
|||||||
+290
-105
@@ -3,94 +3,129 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
)
|
)
|
||||||
|
|
||||||
const historyLimit = 25
|
const historyLimit = 25
|
||||||
|
|
||||||
// downloadsView shows the SABnzbd queue and history and polls while visible.
|
// arrSource is a Radarr or Sonarr instance whose download queue is shown.
|
||||||
|
type arrSource struct {
|
||||||
|
name string
|
||||||
|
queue func(ctx context.Context) ([]arr.QueueItem, error)
|
||||||
|
remove func(ctx context.Context, id int, blocklist bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrRow is one row of the Radarr/Sonarr queue panel.
|
||||||
|
type arrRow struct {
|
||||||
|
source arrSource
|
||||||
|
item arr.QueueItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadsView shows the SABnzbd queue and history plus the Radarr/Sonarr
|
||||||
|
// queues, and polls while visible.
|
||||||
type downloadsView struct {
|
type downloadsView struct {
|
||||||
app *App
|
app *App
|
||||||
sab *sabnzbd.Client
|
sab *sabnzbd.Client
|
||||||
|
sources []arrSource
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
|
||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
summary *tview.TextView
|
summary *tview.TextView
|
||||||
queue *tview.Table
|
queue *tview.Table
|
||||||
|
arrQueue *tview.Table
|
||||||
|
arrDetail *tview.TextView
|
||||||
history *tview.Table
|
history *tview.Table
|
||||||
|
panels []tview.Primitive // focus cycle order
|
||||||
|
|
||||||
// UI-goroutine state.
|
// UI-goroutine state.
|
||||||
slots []sabnzbd.Slot
|
slots []sabnzbd.Slot
|
||||||
done []sabnzbd.HistorySlot
|
done []sabnzbd.HistorySlot
|
||||||
|
arrItems map[string][]arr.QueueItem // last good result per source
|
||||||
|
arrRows []arrRow
|
||||||
|
sabQueue sabnzbd.Queue
|
||||||
paused bool
|
paused bool
|
||||||
cancel context.CancelFunc // stops the poller
|
cancel context.CancelFunc // stops the poller
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDownloadsView(app *App, sab *sabnzbd.Client, interval time.Duration) *downloadsView {
|
func newDownloadsView(app *App, sab *sabnzbd.Client, sources []arrSource, interval time.Duration) *downloadsView {
|
||||||
|
table := func(title string) *tview.Table {
|
||||||
|
t := tview.NewTable().SetSelectable(true, false).SetFixed(1, 0)
|
||||||
|
t.SetBorder(true).SetTitle(title)
|
||||||
|
return t
|
||||||
|
}
|
||||||
v := &downloadsView{
|
v := &downloadsView{
|
||||||
app: app,
|
app: app,
|
||||||
sab: sab,
|
sab: sab,
|
||||||
|
sources: sources,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
summary: tview.NewTextView().SetDynamicColors(true),
|
summary: tview.NewTextView().SetDynamicColors(true),
|
||||||
queue: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
queue: table(" SABnzbd queue "),
|
||||||
history: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
arrQueue: table(" Radarr / Sonarr queue "),
|
||||||
|
arrDetail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||||
|
history: table(" SABnzbd history "),
|
||||||
|
arrItems: map[string][]arr.QueueItem{},
|
||||||
}
|
}
|
||||||
v.queue.SetBorder(true).SetTitle(" SABnzbd queue ")
|
v.panels = []tview.Primitive{v.queue, v.arrQueue, v.history}
|
||||||
v.history.SetBorder(true).SetTitle(" SABnzbd history ")
|
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.summary, 1, 0, false).
|
AddItem(v.summary, 1, 0, false).
|
||||||
AddItem(v.queue, 0, 3, true).
|
AddItem(v.queue, 0, 3, true).
|
||||||
|
AddItem(v.arrQueue, 0, 3, false).
|
||||||
|
AddItem(v.arrDetail, 2, 0, false).
|
||||||
AddItem(v.history, 0, 2, false)
|
AddItem(v.history, 0, 2, false)
|
||||||
|
|
||||||
v.queue.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
common := func(extra map[rune]func()) func(*tcell.EventKey) *tcell.EventKey {
|
||||||
if ev.Key() != tcell.KeyRune {
|
keys := map[rune]func(){
|
||||||
return ev
|
'r': v.refreshNow,
|
||||||
|
'h': v.nextPanel,
|
||||||
|
'p': v.togglePause,
|
||||||
}
|
}
|
||||||
switch ev.Rune() {
|
for r, f := range extra {
|
||||||
case 'p':
|
keys[r] = f
|
||||||
v.togglePause()
|
|
||||||
case 'd':
|
|
||||||
v.deleteSelected()
|
|
||||||
case 'r':
|
|
||||||
v.refreshNow()
|
|
||||||
case 'h':
|
|
||||||
v.app.app.SetFocus(v.history)
|
|
||||||
default:
|
|
||||||
return ev
|
|
||||||
}
|
}
|
||||||
|
return func(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if ev.Key() == tcell.KeyRune {
|
||||||
|
if f, ok := keys[ev.Rune()]; ok {
|
||||||
|
f()
|
||||||
return nil
|
return nil
|
||||||
})
|
}
|
||||||
v.history.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
}
|
||||||
if ev.Key() != tcell.KeyRune {
|
|
||||||
return ev
|
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.queue.SetInputCapture(common(map[rune]func(){'d': v.deleteJob}))
|
||||||
})
|
v.arrQueue.SetInputCapture(common(map[rune]func(){
|
||||||
|
'd': func() { v.removeArr(false) },
|
||||||
|
'b': func() { v.removeArr(true) },
|
||||||
|
}))
|
||||||
|
v.history.SetInputCapture(common(map[rune]func(){'d': v.deleteHistory, 't': v.retryHistory}))
|
||||||
|
v.arrQueue.SetSelectionChangedFunc(func(int, int) { v.renderArrDetail() })
|
||||||
|
|
||||||
v.renderQueue()
|
v.renderQueue()
|
||||||
|
v.renderArr()
|
||||||
v.renderHistory()
|
v.renderHistory()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextPanel moves focus to the next panel (queue, Radarr/Sonarr queue, history).
|
||||||
|
func (v *downloadsView) nextPanel() {
|
||||||
|
cur := v.app.app.GetFocus()
|
||||||
|
for i, p := range v.panels {
|
||||||
|
if p == cur {
|
||||||
|
v.app.app.SetFocus(v.panels[(i+1)%len(v.panels)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.app.app.SetFocus(v.panels[0])
|
||||||
|
}
|
||||||
|
|
||||||
// activate starts polling; called when the view becomes visible.
|
// activate starts polling; called when the view becomes visible.
|
||||||
func (v *downloadsView) activate() {
|
func (v *downloadsView) activate() {
|
||||||
v.deactivate()
|
v.deactivate()
|
||||||
@@ -129,31 +164,73 @@ func (v *downloadsView) refreshNow() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh fetches queue and history off the UI goroutine and applies them.
|
type arrResult struct {
|
||||||
|
items []arr.QueueItem
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh fetches everything in parallel off the UI goroutine and applies the
|
||||||
|
// parts that succeeded. A failing service keeps its last data and reports an error.
|
||||||
func (v *downloadsView) refresh(ctx context.Context) {
|
func (v *downloadsView) refresh(ctx context.Context) {
|
||||||
q, err := v.sab.Queue(ctx)
|
var (
|
||||||
if err != nil {
|
wg sync.WaitGroup
|
||||||
if ctx.Err() == nil {
|
queue sabnzbd.Queue
|
||||||
v.app.post(func() { v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error())) })
|
qerr error
|
||||||
|
history []sabnzbd.HistorySlot
|
||||||
|
herr error
|
||||||
|
arrs = make([]arrResult, len(v.sources))
|
||||||
|
)
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); queue, qerr = v.sab.Queue(ctx) }()
|
||||||
|
go func() { defer wg.Done(); history, herr = v.sab.History(ctx, historyLimit) }()
|
||||||
|
for i, s := range v.sources {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); arrs[i].items, arrs[i].err = s.queue(ctx) }()
|
||||||
}
|
}
|
||||||
return
|
wg.Wait()
|
||||||
}
|
|
||||||
h, herr := v.sab.History(ctx, historyLimit)
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
v.slots, v.paused = q.Slots, q.Paused
|
var errs []string
|
||||||
v.renderSummary(q)
|
if qerr != nil {
|
||||||
|
errs = append(errs, "SABnzbd: "+qerr.Error())
|
||||||
|
} else {
|
||||||
|
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
|
||||||
v.renderQueue()
|
v.renderQueue()
|
||||||
|
}
|
||||||
if herr == nil {
|
if herr == nil {
|
||||||
v.done = h
|
v.done = history
|
||||||
v.renderHistory()
|
v.renderHistory()
|
||||||
}
|
}
|
||||||
|
for i, s := range v.sources {
|
||||||
|
if arrs[i].err != nil {
|
||||||
|
errs = append(errs, s.name+": "+arrs[i].err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v.arrItems[s.name] = arrs[i].items
|
||||||
|
}
|
||||||
|
v.rebuildArrRows()
|
||||||
|
v.renderArr()
|
||||||
|
v.renderSummary()
|
||||||
|
if len(errs) > 0 {
|
||||||
|
v.app.SetMessage("[red]" + tview.Escape(strings.Join(errs, " · ")))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *downloadsView) renderSummary(q sabnzbd.Queue) {
|
func (v *downloadsView) rebuildArrRows() {
|
||||||
|
v.arrRows = v.arrRows[:0]
|
||||||
|
for _, s := range v.sources {
|
||||||
|
for _, it := range v.arrItems[s.name] {
|
||||||
|
v.arrRows = append(v.arrRows, arrRow{source: s, item: it})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *downloadsView) renderSummary() {
|
||||||
|
q := v.sabQueue
|
||||||
var s string
|
var s string
|
||||||
switch {
|
switch {
|
||||||
case q.Paused:
|
case q.Paused:
|
||||||
@@ -170,50 +247,128 @@ func (v *downloadsView) renderSummary(q sabnzbd.Queue) {
|
|||||||
s += " · ETA " + tview.Escape(q.TimeLeft)
|
s += " · ETA " + tview.Escape(q.TimeLeft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
attention := 0
|
||||||
|
for _, r := range v.arrRows {
|
||||||
|
if r.item.NeedsAttention() {
|
||||||
|
attention++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attention > 0 {
|
||||||
|
verb := "need"
|
||||||
|
if attention == 1 {
|
||||||
|
verb = "needs"
|
||||||
|
}
|
||||||
|
s += fmt.Sprintf(" · [red]⚠ %d %s attention[-]", attention, verb)
|
||||||
|
}
|
||||||
v.summary.SetText(s)
|
v.summary.SetText(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func header(t *tview.Table, cols ...string) {
|
// header writes the header row; column expand grows to fill the width.
|
||||||
|
func header(t *tview.Table, expand int, cols ...string) {
|
||||||
for i, c := range cols {
|
for i, c := range cols {
|
||||||
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
||||||
if i == 0 {
|
if i == expand {
|
||||||
cell.SetExpansion(1)
|
cell.SetExpansion(1)
|
||||||
}
|
}
|
||||||
t.SetCell(0, i, cell)
|
t.SetCell(0, i, cell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fill writes data rows starting at row 1; column expand grows to fill the width.
|
||||||
|
func fill(t *tview.Table, expand int, rows [][]string) {
|
||||||
|
for r, cells := range rows {
|
||||||
|
for c, text := range cells {
|
||||||
|
cell := tview.NewTableCell(text)
|
||||||
|
if c == expand {
|
||||||
|
cell.SetExpansion(1)
|
||||||
|
}
|
||||||
|
t.SetCell(r+1, c, cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreSelection selects the row whose id matches sel (or the first row) and
|
||||||
|
// shows placeholder text when there are no rows.
|
||||||
|
func restoreSelection(t *tview.Table, n, selRow int, placeholder string) {
|
||||||
|
if n == 0 {
|
||||||
|
t.SetCell(1, 0, tview.NewTableCell("[gray]"+placeholder).SetSelectable(false))
|
||||||
|
t.Select(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Select(max(selRow, 1), 0)
|
||||||
|
}
|
||||||
|
|
||||||
func (v *downloadsView) renderQueue() {
|
func (v *downloadsView) renderQueue() {
|
||||||
sel := ""
|
sel := ""
|
||||||
if r, _ := v.queue.GetSelection(); r >= 1 && r <= len(v.slots) {
|
if r, _ := v.queue.GetSelection(); r >= 1 && r <= len(v.slots) {
|
||||||
sel = v.slots[r-1].ID
|
sel = v.slots[r-1].ID
|
||||||
}
|
}
|
||||||
v.queue.Clear()
|
v.queue.Clear()
|
||||||
header(v.queue, "Name", "Category", "Status", "Size", "Progress", "ETA")
|
header(v.queue, 0, "Name", "Category", "Status", "Size", "Progress", "ETA")
|
||||||
row := 1
|
rows := make([][]string, len(v.slots))
|
||||||
|
selRow := 0
|
||||||
for i, s := range v.slots {
|
for i, s := range v.slots {
|
||||||
cells := []string{
|
rows[i] = []string{
|
||||||
tview.Escape(s.Name), tview.Escape(s.Category), tview.Escape(s.Status), tview.Escape(s.Size),
|
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),
|
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 {
|
if s.ID == sel {
|
||||||
v.queue.Select(i+1, 0)
|
selRow = i + 1
|
||||||
}
|
}
|
||||||
row++
|
|
||||||
}
|
}
|
||||||
if len(v.slots) == 0 {
|
fill(v.queue, 0, rows)
|
||||||
v.queue.SetCell(1, 0, tview.NewTableCell("[gray]Queue is empty").SetSelectable(false))
|
restoreSelection(v.queue, len(rows), selRow, "Queue is empty")
|
||||||
v.queue.Select(0, 0)
|
}
|
||||||
} else if sel == "" {
|
|
||||||
v.queue.Select(1, 0)
|
func (v *downloadsView) renderArr() {
|
||||||
|
sel := 0
|
||||||
|
if r, _ := v.arrQueue.GetSelection(); r >= 1 {
|
||||||
|
sel = r
|
||||||
}
|
}
|
||||||
|
selKey := ""
|
||||||
|
if sel >= 1 && sel <= len(v.arrRows) {
|
||||||
|
selKey = arrKey(v.arrRows[sel-1])
|
||||||
|
}
|
||||||
|
v.arrQueue.Clear()
|
||||||
|
header(v.arrQueue, 1, "Source", "Title", "Status", "Progress", "Problem")
|
||||||
|
rows := make([][]string, len(v.arrRows))
|
||||||
|
selRow := 0
|
||||||
|
for i, r := range v.arrRows {
|
||||||
|
rows[i] = []string{
|
||||||
|
r.source.name, tview.Escape(r.item.Name()), arrStatusText(r.item),
|
||||||
|
progressBar(r.item.Progress(), 10), tview.Escape(truncate(r.item.Problem(), 40)),
|
||||||
|
}
|
||||||
|
if arrKey(r) == selKey {
|
||||||
|
selRow = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill(v.arrQueue, 1, rows)
|
||||||
|
restoreSelection(v.arrQueue, len(rows), selRow, "Nothing in the Radarr/Sonarr queues")
|
||||||
|
v.renderArrDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrKey(r arrRow) string { return fmt.Sprintf("%s/%d", r.source.name, r.item.ID) }
|
||||||
|
|
||||||
|
// renderArrDetail shows the release title and full problem text of the selected item.
|
||||||
|
func (v *downloadsView) renderArrDetail() {
|
||||||
|
row, ok := v.selectedArr()
|
||||||
|
if !ok {
|
||||||
|
v.arrDetail.SetText("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := "[gray]Release:[-] " + tview.Escape(row.item.Title)
|
||||||
|
if p := row.item.Problem(); p != "" {
|
||||||
|
text += "\n[yellow]" + tview.Escape(p)
|
||||||
|
}
|
||||||
|
v.arrDetail.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *downloadsView) selectedArr() (arrRow, bool) {
|
||||||
|
r, _ := v.arrQueue.GetSelection()
|
||||||
|
if r < 1 || r > len(v.arrRows) {
|
||||||
|
return arrRow{}, false
|
||||||
|
}
|
||||||
|
return v.arrRows[r-1], true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *downloadsView) renderHistory() {
|
func (v *downloadsView) renderHistory() {
|
||||||
@@ -222,8 +377,10 @@ func (v *downloadsView) renderHistory() {
|
|||||||
sel = v.done[r-1].ID
|
sel = v.done[r-1].ID
|
||||||
}
|
}
|
||||||
v.history.Clear()
|
v.history.Clear()
|
||||||
header(v.history, "Name", "Category", "Status", "Size", "Finished")
|
header(v.history, 0, "Name", "Category", "Status", "Size", "Finished")
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
rows := make([][]string, len(v.done))
|
||||||
|
selRow := 0
|
||||||
for i, h := range v.done {
|
for i, h := range v.done {
|
||||||
status := tview.Escape(h.Status)
|
status := tview.Escape(h.Status)
|
||||||
switch {
|
switch {
|
||||||
@@ -235,29 +392,18 @@ func (v *downloadsView) renderHistory() {
|
|||||||
case h.Status == "Completed":
|
case h.Status == "Completed":
|
||||||
status = "[green]" + status + "[-]"
|
status = "[green]" + status + "[-]"
|
||||||
}
|
}
|
||||||
cells := []string{
|
rows[i] = []string{
|
||||||
tview.Escape(h.Name), tview.Escape(h.Category), status, tview.Escape(h.Size), formatAge(h.Completed, now),
|
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 {
|
if h.ID == sel {
|
||||||
v.history.Select(i+1, 0)
|
selRow = i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(v.done) == 0 {
|
fill(v.history, 0, rows)
|
||||||
v.history.SetCell(1, 0, tview.NewTableCell("[gray]No history").SetSelectable(false))
|
restoreSelection(v.history, len(rows), selRow, "No history")
|
||||||
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.
|
// act runs an action off the UI goroutine, reports the outcome and refreshes.
|
||||||
func (v *downloadsView) act(doing, done string, f func(ctx context.Context) error) {
|
func (v *downloadsView) act(doing, done string, f func(ctx context.Context) error) {
|
||||||
v.app.SetMessage(doing)
|
v.app.SetMessage(doing)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -266,7 +412,7 @@ func (v *downloadsView) act(doing, done string, f func(ctx context.Context) erro
|
|||||||
err := f(ctx)
|
err := f(ctx)
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v.app.SetMessage("[green]" + done)
|
v.app.SetMessage("[green]" + done)
|
||||||
@@ -278,15 +424,38 @@ func (v *downloadsView) act(doing, done string, f func(ctx context.Context) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *downloadsView) togglePause() {
|
func (v *downloadsView) togglePause() {
|
||||||
|
wrap := func(f func(context.Context) error) func(context.Context) error {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if err := f(ctx); err != nil {
|
||||||
|
return fmt.Errorf("SABnzbd: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if v.paused {
|
if v.paused {
|
||||||
v.act("Resuming…", "Queue resumed", v.sab.Resume)
|
v.act("Resuming…", "Queue resumed", wrap(v.sab.Resume))
|
||||||
} else {
|
} else {
|
||||||
v.act("Pausing…", "Queue paused", v.sab.Pause)
|
v.act("Pausing…", "Queue paused", wrap(v.sab.Pause))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *downloadsView) deleteSelected() {
|
func (v *downloadsView) deleteJob() {
|
||||||
if v.app.app.GetFocus() == v.history {
|
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 SABnzbd queue?", func() {
|
||||||
|
v.act("Deleting…", "Deleted "+tview.Escape(s.Name), func(ctx context.Context) error {
|
||||||
|
if err := v.sab.DeleteJob(ctx, s.ID, false); err != nil {
|
||||||
|
return fmt.Errorf("SABnzbd: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *downloadsView) deleteHistory() {
|
||||||
r, _ := v.history.GetSelection()
|
r, _ := v.history.GetSelection()
|
||||||
if r < 1 || r > len(v.done) {
|
if r < 1 || r > len(v.done) {
|
||||||
return
|
return
|
||||||
@@ -294,24 +463,15 @@ func (v *downloadsView) deleteSelected() {
|
|||||||
h := v.done[r-1]
|
h := v.done[r-1]
|
||||||
v.app.confirm("Remove "+tview.Escape(h.Name)+" from history?", func() {
|
v.app.confirm("Remove "+tview.Escape(h.Name)+" from history?", func() {
|
||||||
v.act("Removing…", "Removed "+tview.Escape(h.Name), func(ctx context.Context) error {
|
v.act("Removing…", "Removed "+tview.Escape(h.Name), func(ctx context.Context) error {
|
||||||
return v.sab.DeleteHistory(ctx, h.ID, false)
|
if err := v.sab.DeleteHistory(ctx, h.ID, false); err != nil {
|
||||||
})
|
return fmt.Errorf("SABnzbd: %w", err)
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
r, _ := v.queue.GetSelection()
|
return nil
|
||||||
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() {
|
func (v *downloadsView) retryHistory() {
|
||||||
r, _ := v.history.GetSelection()
|
r, _ := v.history.GetSelection()
|
||||||
if r < 1 || r > len(v.done) {
|
if r < 1 || r > len(v.done) {
|
||||||
return
|
return
|
||||||
@@ -322,6 +482,31 @@ func (v *downloadsView) retrySelected() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
v.act("Retrying…", "Retrying "+tview.Escape(h.Name), func(ctx context.Context) error {
|
v.act("Retrying…", "Retrying "+tview.Escape(h.Name), func(ctx context.Context) error {
|
||||||
return v.sab.Retry(ctx, h.ID)
|
if err := v.sab.Retry(ctx, h.ID); err != nil {
|
||||||
|
return fmt.Errorf("SABnzbd: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeArr removes the selected Radarr/Sonarr queue item, optionally blocklisting the release.
|
||||||
|
func (v *downloadsView) removeArr(blocklist bool) {
|
||||||
|
row, ok := v.selectedArr()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := "Remove " + tview.Escape(row.item.Name()) + " from " + row.source.name + " and the download client?"
|
||||||
|
done := "Removed " + tview.Escape(row.item.Name())
|
||||||
|
if blocklist {
|
||||||
|
q = "Blocklist this release of " + tview.Escape(row.item.Name()) + " and search for another one?"
|
||||||
|
done = "Blocklisted " + tview.Escape(row.item.Name())
|
||||||
|
}
|
||||||
|
v.app.confirm(q, func() {
|
||||||
|
v.act("Working…", done, func(ctx context.Context) error {
|
||||||
|
if err := row.source.remove(ctx, row.item.ID, blocklist); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", row.source.name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,3 +194,32 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return string(r[:n-1]) + "…"
|
return string(r[:n-1]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arrStatusText renders the state of a Radarr/Sonarr queue item, colored by severity.
|
||||||
|
func arrStatusText(it arr.QueueItem) string {
|
||||||
|
label := map[string]string{
|
||||||
|
"downloading": "Downloading",
|
||||||
|
"importPending": "Import pending",
|
||||||
|
"importBlocked": "Import blocked",
|
||||||
|
"importing": "Importing",
|
||||||
|
"imported": "Imported",
|
||||||
|
"failedPending": "Failed (pending)",
|
||||||
|
"failed": "Failed",
|
||||||
|
"ignored": "Ignored",
|
||||||
|
}[it.TrackedDownloadState]
|
||||||
|
if label == "" {
|
||||||
|
label = it.Status
|
||||||
|
if label != "" {
|
||||||
|
label = strings.ToUpper(label[:1]) + label[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label = tview.Escape(label)
|
||||||
|
switch {
|
||||||
|
case it.TrackedDownloadStatus == "error" || it.TrackedDownloadState == "failed" ||
|
||||||
|
it.TrackedDownloadState == "failedPending" || it.TrackedDownloadState == "importBlocked":
|
||||||
|
return "[red]" + label + "[-]"
|
||||||
|
case it.TrackedDownloadStatus == "warning":
|
||||||
|
return "[yellow]" + label + "[-]"
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,3 +136,22 @@ func TestTruncate(t *testing.T) {
|
|||||||
t.Errorf("rune handling: %q", got)
|
t.Errorf("rune handling: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArrStatusText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
item arr.QueueItem
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"downloading", arr.QueueItem{TrackedDownloadState: "downloading", TrackedDownloadStatus: "ok"}, "Downloading"},
|
||||||
|
{"blocked", arr.QueueItem{TrackedDownloadState: "importBlocked", TrackedDownloadStatus: "warning"}, "[red]Import blocked[-]"},
|
||||||
|
{"warning", arr.QueueItem{TrackedDownloadState: "importPending", TrackedDownloadStatus: "warning"}, "[yellow]Import pending[-]"},
|
||||||
|
{"error", arr.QueueItem{Status: "failed", TrackedDownloadStatus: "error"}, "[red]Failed[-]"},
|
||||||
|
{"fallback to status", arr.QueueItem{Status: "queued"}, "Queued"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := arrStatusText(tc.item); got != tc.want {
|
||||||
|
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user