diff --git a/SPECS.md b/SPECS.md index 6317592..ad7fbac 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:** M4 done +- **Status:** M5 done ## 1. Goals @@ -107,9 +107,10 @@ Keybindings (initial proposal): | `a` | Add selected result | | `m` / `s` | Switch to movies / series search | | `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) | -| `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 | | `g` | Grab the selected release | | `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] **M3** — Sonarr client and series search + add flow. - [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. - [ ] **M7** — Polish: help screen, error handling, README, release builds. - [ ] **M8+** — v0.2 features (library view, series drill-down). diff --git a/dev/README.md b/dev/README.md index 69dab5d..5623957 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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. + +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 +``` diff --git a/internal/arr/queue.go b/internal/arr/queue.go new file mode 100644 index 0000000..02238af --- /dev/null +++ b/internal/arr/queue.go @@ -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) +} diff --git a/internal/arr/queue_test.go b/internal/arr/queue_test.go new file mode 100644 index 0000000..55c4f1e --- /dev/null +++ b/internal/arr/queue_test.go @@ -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) + } +} diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index 92de9d1..14de08c 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -140,3 +140,8 @@ func (c *Client) SearchMovie(ctx context.Context, movieID int) error { // TMDBTerm builds a lookup term for a TMDb id. 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"}}) +} diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index 72a49f3..e0d84de 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -161,3 +161,8 @@ func (c *Client) SearchSeries(ctx context.Context, seriesID int) error { body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID} 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"}}) +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 60f229a..06b3de9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -47,10 +47,14 @@ 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) + 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{ {key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root}, {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root}, diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go index 7116a16..e5896df 100644 --- a/internal/ui/downloads.go +++ b/internal/ui/downloads.go @@ -3,94 +3,129 @@ package ui import ( "context" "fmt" + "strings" + "sync" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" ) 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 { app *App sab *sabnzbd.Client + sources []arrSource interval time.Duration - root *tview.Flex - summary *tview.TextView - queue *tview.Table - history *tview.Table + root *tview.Flex + summary *tview.TextView + queue *tview.Table + arrQueue *tview.Table + arrDetail *tview.TextView + history *tview.Table + panels []tview.Primitive // focus cycle order // UI-goroutine state. - slots []sabnzbd.Slot - done []sabnzbd.HistorySlot - paused bool - cancel context.CancelFunc // stops the poller + slots []sabnzbd.Slot + done []sabnzbd.HistorySlot + arrItems map[string][]arr.QueueItem // last good result per source + arrRows []arrRow + sabQueue sabnzbd.Queue + 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), +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.queue.SetBorder(true).SetTitle(" SABnzbd queue ") - v.history.SetBorder(true).SetTitle(" SABnzbd history ") + v := &downloadsView{ + app: app, + sab: sab, + sources: sources, + interval: interval, + summary: tview.NewTextView().SetDynamicColors(true), + queue: table(" SABnzbd queue "), + arrQueue: table(" Radarr / Sonarr queue "), + arrDetail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), + history: table(" SABnzbd history "), + arrItems: map[string][]arr.QueueItem{}, + } + v.panels = []tview.Primitive{v.queue, v.arrQueue, v.history} v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.summary, 1, 0, false). AddItem(v.queue, 0, 3, true). + AddItem(v.arrQueue, 0, 3, false). + AddItem(v.arrDetail, 2, 0, false). AddItem(v.history, 0, 2, false) - v.queue.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { - if ev.Key() != tcell.KeyRune { + common := func(extra map[rune]func()) func(*tcell.EventKey) *tcell.EventKey { + keys := map[rune]func(){ + 'r': v.refreshNow, + 'h': v.nextPanel, + 'p': v.togglePause, + } + for r, f := range extra { + keys[r] = f + } + return func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyRune { + if f, ok := keys[ev.Rune()]; ok { + f() + return nil + } + } 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.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.renderArr() v.renderHistory() 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. func (v *downloadsView) activate() { 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) { - 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 + var ( + wg sync.WaitGroup + queue sabnzbd.Queue + 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) }() } - h, herr := v.sab.History(ctx, historyLimit) + wg.Wait() if ctx.Err() != nil { return } + v.app.post(func() { - v.slots, v.paused = q.Slots, q.Paused - v.renderSummary(q) - v.renderQueue() + var errs []string + if qerr != nil { + errs = append(errs, "SABnzbd: "+qerr.Error()) + } else { + v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused + v.renderQueue() + } if herr == nil { - v.done = h + v.done = history 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 switch { case q.Paused: @@ -170,50 +247,128 @@ func (v *downloadsView) renderSummary(q sabnzbd.Queue) { 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) } -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 { cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) - if i == 0 { + if i == expand { cell.SetExpansion(1) } 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() { 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 + header(v.queue, 0, "Name", "Category", "Status", "Size", "Progress", "ETA") + rows := make([][]string, len(v.slots)) + selRow := 0 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), 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) + selRow = i + 1 } - 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) + fill(v.queue, 0, rows) + restoreSelection(v.queue, len(rows), selRow, "Queue is empty") +} + +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() { @@ -222,8 +377,10 @@ func (v *downloadsView) renderHistory() { sel = v.done[r-1].ID } v.history.Clear() - header(v.history, "Name", "Category", "Status", "Size", "Finished") + header(v.history, 0, "Name", "Category", "Status", "Size", "Finished") now := time.Now() + rows := make([][]string, len(v.done)) + selRow := 0 for i, h := range v.done { status := tview.Escape(h.Status) switch { @@ -235,29 +392,18 @@ func (v *downloadsView) renderHistory() { case h.Status == "Completed": 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), } - 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) + selRow = i + 1 } } - 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) - } + fill(v.history, 0, rows) + restoreSelection(v.history, len(rows), selRow, "No history") } -// 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) { v.app.SetMessage(doing) go func() { @@ -266,7 +412,7 @@ func (v *downloadsView) act(doing, done string, f func(ctx context.Context) erro err := f(ctx) v.app.post(func() { if err != nil { - v.app.SetMessage("[red]SABnzbd: " + tview.Escape(err.Error())) + v.app.SetMessage("[red]" + tview.Escape(err.Error())) return } v.app.SetMessage("[green]" + done) @@ -278,40 +424,54 @@ func (v *downloadsView) act(doing, done string, f func(ctx context.Context) erro } 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 { - v.act("Resuming…", "Queue resumed", v.sab.Resume) + v.act("Resuming…", "Queue resumed", wrap(v.sab.Resume)) } else { - v.act("Pausing…", "Queue paused", v.sab.Pause) + v.act("Pausing…", "Queue paused", wrap(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 - } +func (v *downloadsView) deleteJob() { 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.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 { - return v.sab.DeleteJob(ctx, s.ID, false) + if err := v.sab.DeleteJob(ctx, s.ID, false); err != nil { + return fmt.Errorf("SABnzbd: %w", err) + } + return nil }) }) } -func (v *downloadsView) retrySelected() { +func (v *downloadsView) deleteHistory() { + 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 { + if err := v.sab.DeleteHistory(ctx, h.ID, false); err != nil { + return fmt.Errorf("SABnzbd: %w", err) + } + return nil + }) + }) +} + +func (v *downloadsView) retryHistory() { r, _ := v.history.GetSelection() if r < 1 || r > len(v.done) { return @@ -322,6 +482,31 @@ func (v *downloadsView) retrySelected() { return } 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 + }) }) } diff --git a/internal/ui/format.go b/internal/ui/format.go index f8ef7c8..32896eb 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -194,3 +194,32 @@ func truncate(s string, n int) string { } 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 +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index 5342e5f..1c4d2f9 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -136,3 +136,22 @@ func TestTruncate(t *testing.T) { 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) + } + } +}