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:
@@ -0,0 +1,216 @@
|
||||
// Package sabnzbd is a client for the SABnzbd HTTP API.
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client talks to a SABnzbd instance.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a client for the instance at baseURL.
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
|
||||
type Number float64
|
||||
|
||||
// UnmarshalJSON accepts numbers, numeric strings, empty strings and null.
|
||||
func (n *Number) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
*n = 0
|
||||
return nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing number %q: %w", s, err)
|
||||
}
|
||||
*n = Number(f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Slot is a job in the download queue.
|
||||
type Slot struct {
|
||||
ID string `json:"nzo_id"`
|
||||
Name string `json:"filename"`
|
||||
Category string `json:"cat"`
|
||||
Status string `json:"status"` // Downloading, Queued, Paused, Fetching, ...
|
||||
Priority string `json:"priority"`
|
||||
Size string `json:"size"`
|
||||
SizeLeft string `json:"sizeleft"`
|
||||
MB Number `json:"mb"`
|
||||
MBLeft Number `json:"mbleft"`
|
||||
Percentage Number `json:"percentage"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
}
|
||||
|
||||
// Queue is the current download queue.
|
||||
type Queue struct {
|
||||
Status string `json:"status"` // Downloading, Idle, Paused
|
||||
Paused bool `json:"paused"`
|
||||
KBPerSec Number `json:"kbpersec"`
|
||||
SpeedLimit Number `json:"speedlimit_abs"` // bytes/s, 0 = unlimited
|
||||
Size string `json:"size"`
|
||||
SizeLeft string `json:"sizeleft"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
TotalSlots int `json:"noofslots_total"`
|
||||
Slots []Slot `json:"slots"`
|
||||
}
|
||||
|
||||
// HistorySlot is a finished (or post-processing) job.
|
||||
type HistorySlot struct {
|
||||
ID string `json:"nzo_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"` // Completed, Failed, Extracting, ...
|
||||
Size string `json:"size"`
|
||||
FailMessage string `json:"fail_message"`
|
||||
Completed int64 `json:"completed"` // unix time
|
||||
}
|
||||
|
||||
// Failed reports whether the job failed.
|
||||
func (h HistorySlot) Failed() bool { return h.Status == "Failed" }
|
||||
|
||||
// Version returns the SABnzbd version string.
|
||||
func (c *Client) Version(ctx context.Context) (string, error) {
|
||||
var r struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
err := c.call(ctx, url.Values{"mode": {"version"}}, &r)
|
||||
return r.Version, err
|
||||
}
|
||||
|
||||
// Queue fetches the download queue.
|
||||
func (c *Client) Queue(ctx context.Context) (Queue, error) {
|
||||
var r struct {
|
||||
Queue Queue `json:"queue"`
|
||||
}
|
||||
err := c.call(ctx, url.Values{"mode": {"queue"}}, &r)
|
||||
return r.Queue, err
|
||||
}
|
||||
|
||||
// History fetches the most recent limit history entries.
|
||||
func (c *Client) History(ctx context.Context, limit int) ([]HistorySlot, error) {
|
||||
var r struct {
|
||||
History struct {
|
||||
Slots []HistorySlot `json:"slots"`
|
||||
} `json:"history"`
|
||||
}
|
||||
err := c.call(ctx, url.Values{"mode": {"history"}, "limit": {strconv.Itoa(limit)}}, &r)
|
||||
return r.History.Slots, err
|
||||
}
|
||||
|
||||
// Pause pauses the whole queue.
|
||||
func (c *Client) Pause(ctx context.Context) error {
|
||||
return c.call(ctx, url.Values{"mode": {"pause"}}, nil)
|
||||
}
|
||||
|
||||
// Resume resumes the whole queue.
|
||||
func (c *Client) Resume(ctx context.Context) error {
|
||||
return c.call(ctx, url.Values{"mode": {"resume"}}, nil)
|
||||
}
|
||||
|
||||
// DeleteJob removes a job from the queue, optionally deleting its files.
|
||||
func (c *Client) DeleteJob(ctx context.Context, id string, deleteFiles bool) error {
|
||||
return c.call(ctx, url.Values{
|
||||
"mode": {"queue"}, "name": {"delete"}, "value": {id}, "del_files": {boolParam(deleteFiles)},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// DeleteHistory removes a history entry, optionally deleting its files.
|
||||
func (c *Client) DeleteHistory(ctx context.Context, id string, deleteFiles bool) error {
|
||||
return c.call(ctx, url.Values{
|
||||
"mode": {"history"}, "name": {"delete"}, "value": {id}, "del_files": {boolParam(deleteFiles)},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// Retry re-queues a failed job from the history.
|
||||
func (c *Client) Retry(ctx context.Context, id string) error {
|
||||
return c.call(ctx, url.Values{"mode": {"retry"}, "value": {id}}, nil)
|
||||
}
|
||||
|
||||
func boolParam(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// call performs an API request. SABnzbd reports failures with HTTP 200 and
|
||||
// {"status": false, "error": "..."}, which is turned into an error here.
|
||||
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||
params.Set("output", "json")
|
||||
params.Set("apikey", c.apiKey)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return stripURL(err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return stripURL(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return stripURL(err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
|
||||
}
|
||||
|
||||
var failure struct {
|
||||
Status any `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &failure); err != nil {
|
||||
// e.g. the plain-text "Access denied - Hostname verification failed"
|
||||
return fmt.Errorf("unexpected response: %s", snippet(body))
|
||||
}
|
||||
if failure.Error != "" {
|
||||
return errors.New(failure.Error)
|
||||
}
|
||||
if ok, isBool := failure.Status.(bool); isBool && !ok {
|
||||
return errors.New("request failed")
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snippet(b []byte) string {
|
||||
return strings.TrimSpace(string(b[:min(len(b), 200)]))
|
||||
}
|
||||
|
||||
// stripURL drops the request URL from transport errors: it contains the API key.
|
||||
func stripURL(err error) error {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return fmt.Errorf("%s: %w", ue.Op, ue.Err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const queueJSON = `{"queue":{"status":"Downloading","paused":false,"kbpersec":"1536.50","speedlimit_abs":"0",
|
||||
"size":"4.0 GB","sizeleft":"2.0 GB","timeleft":"0:21:10","noofslots_total":2,
|
||||
"slots":[
|
||||
{"nzo_id":"SABnzbd_nzo_a","filename":"Movie.2020.1080p","cat":"radarr","status":"Downloading","priority":"Normal",
|
||||
"size":"3 GB","sizeleft":"1 GB","mb":"3072.00","mbleft":"1024.00","percentage":"67","timeleft":"0:11:10"},
|
||||
{"nzo_id":"SABnzbd_nzo_b","filename":"Show.S01E01","cat":"sonarr","status":"Queued","mb":1000,"mbleft":1000,"percentage":0}
|
||||
]}}`
|
||||
|
||||
func newTestClient(t *testing.T, h http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient(srv.URL, "secret")
|
||||
}
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("mode") != "queue" || q.Get("apikey") != "secret" || q.Get("output") != "json" {
|
||||
t.Errorf("unexpected request %s", r.URL)
|
||||
}
|
||||
w.Write([]byte(queueJSON))
|
||||
})
|
||||
q, err := c.Queue(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Paused || q.KBPerSec != 1536.5 || q.TotalSlots != 2 || len(q.Slots) != 2 {
|
||||
t.Fatalf("queue = %+v", q)
|
||||
}
|
||||
s := q.Slots[0]
|
||||
if s.ID != "SABnzbd_nzo_a" || s.Percentage != 67 || s.MB != 3072 || s.Category != "radarr" {
|
||||
t.Errorf("slot = %+v", s)
|
||||
}
|
||||
if q.Slots[1].Percentage != 0 || q.Slots[1].MB != 1000 {
|
||||
t.Errorf("numeric (non-string) values not decoded: %+v", q.Slots[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("mode") != "history" || r.URL.Query().Get("limit") != "10" {
|
||||
t.Errorf("unexpected request %s", r.URL)
|
||||
}
|
||||
w.Write([]byte(`{"history":{"slots":[
|
||||
{"nzo_id":"a","name":"Good","status":"Completed","size":"1 GB","completed":1700000000},
|
||||
{"nzo_id":"b","name":"Bad","status":"Failed","fail_message":"Aborted, cannot be completed"}]}}`))
|
||||
})
|
||||
h, err := c.History(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(h) != 2 || h[0].Failed() || !h[1].Failed() || h[1].FailMessage == "" {
|
||||
t.Fatalf("history = %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActions(t *testing.T) {
|
||||
var got []string
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
got = append(got, strings.Join([]string{q.Get("mode"), q.Get("name"), q.Get("value"), q.Get("del_files")}, "|"))
|
||||
w.Write([]byte(`{"status":true}`))
|
||||
})
|
||||
ctx := context.Background()
|
||||
for _, err := range []error{
|
||||
c.Pause(ctx), c.Resume(ctx),
|
||||
c.DeleteJob(ctx, "id1", true), c.DeleteHistory(ctx, "id2", false), c.Retry(ctx, "id3"),
|
||||
} {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := []string{"pause|||", "resume|||", "queue|delete|id1|1", "history|delete|id2|0", "retry||id3|"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("requests = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
"bad key": {200, `{"status":false,"error":"API Key Incorrect"}`, "API Key Incorrect"},
|
||||
"host check": {200, `Access denied - Hostname verification failed`, "Hostname verification failed"},
|
||||
"http error": {500, `boom`, "HTTP 500: boom"},
|
||||
"status false": {200, `{"status":false}`, "request failed"},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
w.Write([]byte(tc.body))
|
||||
})
|
||||
_, err := c.Version(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportErrorHidesAPIKey(t *testing.T) {
|
||||
c := NewClient("http://127.0.0.1:1", "topsecretkey")
|
||||
_, err := c.Version(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "topsecretkey") {
|
||||
t.Errorf("error leaks API key: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user