Session 3: Radarr/Sonarr/Lidarr clients with add-on-search

Adds internal/arr with a shared HTTP layer and per-service clients that
look up candidates and POST the chosen one back with monitored=true and
search-on-add enabled. Lidarr requires metadata_profile_id, now part of
ArrConfig and validated when lidarr is set.
This commit is contained in:
prosolis
2026-05-24 20:22:50 -07:00
parent 5c7d21e574
commit 206b378d93
9 changed files with 537 additions and 1 deletions

128
internal/arr/arr.go Normal file
View File

@@ -0,0 +1,128 @@
// 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)
}