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
+130
View File
@@ -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)
}
+72
View File
@@ -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)
}
}