// 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 }