// Package arr provides clients for Radarr, Sonarr, and Lidarr. // // Each client exposes Search to look up candidates by free-text query and // Add to enqueue the first/chosen candidate for download. Lookup responses // are preserved as raw JSON inside Result so Add can replay the entity back // to the *arr server with the configured profile and root folder layered on. package arr import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Result is a single candidate returned by an *arr lookup. Title and Year // are extracted for display; raw is the untouched JSON object used to build // the Add payload. type Result struct { Title string Year int raw json.RawMessage } // Raw exposes the underlying lookup JSON; useful for tests and debugging. func (r Result) Raw() json.RawMessage { return r.raw } // Client is the common interface implemented by every *arr backend. type Client interface { Search(ctx context.Context, term string) ([]Result, error) Add(ctx context.Context, r Result) error } type httpClient struct { baseURL string apiKey string hc *http.Client } func newHTTP(baseURL, apiKey string) *httpClient { return &httpClient{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hc: &http.Client{Timeout: 30 * time.Second}, } } func (c *httpClient) get(ctx context.Context, path string, query map[string]string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return nil, err } if len(query) > 0 { q := req.URL.Query() for k, v := range query { q.Set(k, v) } req.URL.RawQuery = q.Encode() } return c.do(req) } func (c *httpClient) post(ctx context.Context, path string, body []byte) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return c.do(req) } func (c *httpClient) do(req *http.Request) ([]byte, error) { req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("%s %s: %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body))) } return body, nil } // parseLookup decodes a lookup response into Results, pulling Title/Year out // of each item while keeping the original JSON object intact. func parseLookup(body []byte, titleField string) ([]Result, error) { var items []json.RawMessage if err := json.Unmarshal(body, &items); err != nil { return nil, fmt.Errorf("decode lookup: %w", err) } out := make([]Result, 0, len(items)) for _, item := range items { var meta map[string]json.RawMessage if err := json.Unmarshal(item, &meta); err != nil { continue } var title string _ = json.Unmarshal(meta[titleField], &title) var year int _ = json.Unmarshal(meta["year"], &year) out = append(out, Result{Title: title, Year: year, raw: item}) } return out, nil } // buildAddBody decodes the raw lookup item into a generic map, overlays the // provided fields, and re-marshals. Each *arr backend supplies the fields // specific to its add endpoint. func buildAddBody(raw json.RawMessage, overlay map[string]any) ([]byte, error) { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("decode lookup item: %w", err) } for k, v := range overlay { m[k] = v } return json.Marshal(m) }