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:
2026-10-20 18:03:16 +02:00
co-authored by Claude Sonnet 5.5
parent aeeb2c5f50
commit 446cbdd6c5
10 changed files with 591 additions and 131 deletions
+310 -125
View File
@@ -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
})
})
}