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
+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)
}
}