Without a config file arr-tui refused to start. It now creates an empty one (private, with its directory) and opens the Config view. A service with neither address nor key is "not configured", which validation allows: its client returns ErrNotConfigured without a request, its indicator stays grey, it is logged once as information, and its lists say so instead of showing errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
237 lines
6.6 KiB
Go
237 lines
6.6 KiB
Go
// 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"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Client talks to a SABnzbd instance.
|
|
type Client struct {
|
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
|
baseURL string
|
|
apiKey string
|
|
http *http.Client
|
|
}
|
|
|
|
// Reconfigure points the client at another instance or key.
|
|
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.baseURL = strings.TrimRight(baseURL, "/")
|
|
c.apiKey = apiKey
|
|
}
|
|
|
|
// 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},
|
|
}
|
|
}
|
|
|
|
// ErrNotConfigured is returned without a request while the client has no
|
|
// address: the service is not set up.
|
|
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
|
|
|
// 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")
|
|
c.mu.RLock()
|
|
baseURL, apiKey := c.baseURL, c.apiKey
|
|
c.mu.RUnlock()
|
|
if baseURL == "" {
|
|
return ErrNotConfigured
|
|
}
|
|
params.Set("apikey", apiKey)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, 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
|
|
}
|