- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta] - Auto-detect instances via Docker, config files, port scan, Prowlarr - Import wanted list (monitored+missing movies/series) - Import download history and blocklist to avoid re-downloading - Extract debrid tokens from *arr download clients - Quality profile mapping to preferred_quality config - DISTINCT ON PostgreSQL query for optimal torrent selection - JSON export with --dry-run --json (text to stderr, JSON to stdout) - Media server detection (Plex/Jellyfin/Emby) in unarr init - Detects library paths and offers them as download directory options - Debrid auto-configuration in unarr init - Scans *arr instances for debrid tokens - Validates and saves via API if user confirms - New preferred_quality setting in config (2160p/1080p/720p) - Library scan command (unarr scan) with ffprobe metadata extraction
188 lines
5.4 KiB
Go
188 lines
5.4 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
|
|
}
|