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
+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]) + "…"
}