unarr/internal/arr/client.go

188 lines
5.5 KiB
Go

package arr
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client talks to a single *arr instance (Sonarr, Radarr, or Prowlarr).
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewClient creates a client for the given *arr instance.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// SystemStatus returns version and app info. Works with all *arr apps.
func (c *Client) SystemStatus() (*SystemStatus, error) {
// Try v3 first (Sonarr/Radarr), then v1 (Prowlarr)
var s SystemStatus
if err := c.get("/api/v3/system/status", &s); err != nil {
if err2 := c.get("/api/v1/system/status", &s); err2 != nil {
return nil, fmt.Errorf("system/status v3: %w; v1: %v", err, err2)
}
}
return &s, nil
}
// ── Radarr ──────────────────────────────────────────────────────────
func (c *Client) Movies() ([]Movie, error) {
var m []Movie
if err := c.get("/api/v3/movie", &m); err != nil {
return nil, fmt.Errorf("movies: %w", err)
}
return m, nil
}
// ── Sonarr ──────────────────────────────────────────────────────────
func (c *Client) Series() ([]Series, error) {
var s []Series
if err := c.get("/api/v3/series", &s); err != nil {
return nil, fmt.Errorf("series: %w", err)
}
return s, nil
}
// ── Shared (Sonarr + Radarr use the same v3 endpoints) ─────────────
func (c *Client) QualityProfiles() ([]QualityProfile, error) {
var p []QualityProfile
if err := c.get("/api/v3/qualityprofile", &p); err != nil {
return nil, fmt.Errorf("quality profiles: %w", err)
}
return p, nil
}
func (c *Client) RootFolders() ([]RootFolder, error) {
var f []RootFolder
if err := c.get("/api/v3/rootfolder", &f); err != nil {
return nil, fmt.Errorf("root folders: %w", err)
}
return f, nil
}
func (c *Client) DownloadClients() ([]DownloadClient, error) {
var d []DownloadClient
if err := c.get("/api/v3/downloadclient", &d); err != nil {
return nil, fmt.Errorf("download clients: %w", err)
}
return d, nil
}
// DownloadClientDetails returns the full config (including fields) for a single download client.
func (c *Client) DownloadClientDetails(id int) ([]Field, error) {
path := fmt.Sprintf("/api/v3/downloadclient/%d", id)
var dc struct {
Fields []Field `json:"fields"`
}
if err := c.get(path, &dc); err != nil {
return nil, err
}
return dc.Fields, nil
}
// ── Shared (Sonarr + Radarr) ────────────────────────────────────────
func (c *Client) Tags() ([]Tag, error) {
var t []Tag
if err := c.get("/api/v3/tag", &t); err != nil {
return nil, fmt.Errorf("tags: %w", err)
}
return t, nil
}
// History returns download history records (grabbed + imported).
// pageSize controls how many records per page (max 250).
func (c *Client) History(pageSize int) ([]HistoryRecord, error) {
if pageSize <= 0 {
pageSize = 250
}
path := fmt.Sprintf("/api/v3/history?page=1&pageSize=%d&sortKey=date&sortDirection=descending", pageSize)
var resp HistoryResponse
if err := c.get(path, &resp); err != nil {
return nil, fmt.Errorf("history: %w", err)
}
return resp.Records, nil
}
// Blocklist returns releases the user has explicitly rejected.
func (c *Client) Blocklist(pageSize int) ([]BlocklistItem, error) {
if pageSize <= 0 {
pageSize = 250
}
path := fmt.Sprintf("/api/v3/blocklist?page=1&pageSize=%d", pageSize)
var resp BlocklistResponse
if err := c.get(path, &resp); err != nil {
return nil, fmt.Errorf("blocklist: %w", err)
}
return resp.Records, nil
}
// ── Prowlarr ────────────────────────────────────────────────────────
func (c *Client) Indexers() ([]Indexer, error) {
var idx []Indexer
if err := c.get("/api/v1/indexer", &idx); err != nil {
return nil, fmt.Errorf("indexers: %w", err)
}
return idx, nil
}
func (c *Client) Applications() ([]Application, error) {
var apps []Application
if err := c.get("/api/v1/applications", &apps); err != nil {
return nil, fmt.Errorf("applications: %w", err)
}
return apps, nil
}
// ── HTTP helper ─────────────────────────────────────────────────────
func (c *Client) get(path string, dst any) error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20)) // 50MB limit for large libraries
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("unauthorized — check your API key")
}
if resp.StatusCode >= 400 {
msg := string(body)
if len(msg) > 200 {
msg = msg[:200] + "..."
}
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
}
if err := json.Unmarshal(body, dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}