Add SABnzbd client and Downloads view (M4)

SABnzbd client (queue, history, pause/resume, delete, retry) with lenient
number decoding and API-key-safe errors. Downloads view polls while
visible, shows queue and history, and supports pause, delete (with
confirmation) and retry. Add dev/ with pinned Radarr/Sonarr/SABnzbd
containers wired together for manual testing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:58:32 +02:00
co-authored by Claude Sonnet 5.5
parent 2dd7c17737
commit aeeb2c5f50
12 changed files with 1261 additions and 9 deletions
+32 -4
View File
@@ -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()
+327
View File
@@ -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)
})
}
+45
View File
@@ -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]) + "…"
}
+50
View File
@@ -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)
}
}