// Package arr is a minimal typed client for the sonarr/radarr v3 APIs, covering // only what mediamark needs: the series/movie list (for titles, artwork ids and // metadata) and a poster proxy. // // The api key is read from a mounted secret on every call so a Vault Secrets // Operator rotation is picked up without a restart, and it is never echoed into // a response or an error string. package arr import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path" "strconv" "strings" "sync" "time" ) // maxListBytes caps a list response; the estate's libraries are a few MB of // JSON at most and an unbounded read from a compromised upstream is a DoS. const maxListBytes = 32 << 20 // maxArtBytes caps a proxied poster. const maxArtBytes = 16 << 20 // ErrNoKey is returned when no api key is available for the app. var ErrNoKey = errors.New("no api key available") // Kind identifies which *arr an entry came from. type Kind string // The supported *arr kinds. const ( KindSonarr Kind = "sonarr" KindRadarr Kind = "radarr" ) // Entry is the subset of a series/movie record mediamark shows. type Entry struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Overview string `json:"overview"` Status string `json:"status"` // Path is the *arr's on-disk directory; its basename matches the library // directory name. Path string `json:"path"` } // Dir returns the library directory name for the entry. func (e Entry) Dir() string { if e.Path == "" { return "" } return path.Base(strings.TrimRight(strings.ReplaceAll(e.Path, `\`, "/"), "/")) } // KeyFunc returns the api key for an app name ("sonarr"/"radarr"). type KeyFunc func(app string) (string, bool) // Client talks to one *arr instance. type Client struct { kind Kind baseURL string key KeyFunc http *http.Client mu sync.Mutex cached []Entry cachedAt time.Time ttl time.Duration } // New builds a Client for baseURL (which already includes the instance UrlBase). func New(kind Kind, baseURL string, key KeyFunc) *Client { return &Client{ kind: kind, baseURL: strings.TrimRight(baseURL, "/"), key: key, http: &http.Client{Timeout: 30 * time.Second}, ttl: 60 * time.Second, } } // SetCacheTTL overrides the list cache lifetime (tests use a zero TTL). func (c *Client) SetCacheTTL(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.ttl = d } // listPath is the collection endpoint for the kind. func (c *Client) listPath() string { if c.kind == KindSonarr { return "/api/v3/series" } return "/api/v3/movie" } // List returns the instance's entries, served from a short-lived cache so list // pages stay fast. A failed refresh does not poison the cache. func (c *Client) List(ctx context.Context) ([]Entry, error) { c.mu.Lock() if c.cached != nil && c.ttl > 0 && time.Since(c.cachedAt) < c.ttl { out := c.cached c.mu.Unlock() return out, nil } c.mu.Unlock() entries, err := c.fetchList(ctx) if err != nil { return nil, err } c.mu.Lock() c.cached, c.cachedAt = entries, time.Now() c.mu.Unlock() return entries, nil } func (c *Client) fetchList(ctx context.Context) ([]Entry, error) { resp, err := c.do(ctx, c.listPath()) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s list: unexpected status %d", c.kind, resp.StatusCode) } out := []Entry{} if err := json.NewDecoder(io.LimitReader(resp.Body, maxListBytes)).Decode(&out); err != nil { return nil, fmt.Errorf("%s list: decode: %w", c.kind, err) } return out, nil } // Art is a proxied poster response the caller must close. type Art struct { Body io.ReadCloser ContentType string } // Poster streams the 250px poster for an entry id. func (c *Client) Poster(ctx context.Context, id int) (*Art, error) { resp, err := c.do(ctx, "/api/v3/mediacover/"+strconv.Itoa(id)+"/poster-250.jpg") if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() return nil, fmt.Errorf("%s poster %d: unexpected status %d", c.kind, id, resp.StatusCode) } ct := resp.Header.Get("Content-Type") if ct == "" { ct = "image/jpeg" } return &Art{ Body: struct { io.Reader io.Closer }{io.LimitReader(resp.Body, maxArtBytes), resp.Body}, ContentType: ct, }, nil } // do issues an authenticated GET. Errors deliberately name only the app and // path so a key can never reach a log line or an HTTP body. func (c *Client) do(ctx context.Context, p string) (*http.Response, error) { key, ok := c.key(string(c.kind)) if !ok { return nil, fmt.Errorf("%s: %w", c.kind, ErrNoKey) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+p, nil) if err != nil { return nil, fmt.Errorf("%s %s: build request", c.kind, p) } req.Header.Set("X-Api-Key", key) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("%s %s: request failed", c.kind, p) } return resp, nil } // FileKeys reads *arr api keys from a mounted secret directory, re-reading on // every call so rotations land without a restart. ARR__APIKEY overrides it // for local development. func FileKeys(dir string) KeyFunc { return func(app string) (string, bool) { env := "ARR_" + strings.ToUpper(app) + "_APIKEY" if v := strings.TrimSpace(os.Getenv(env)); v != "" { return v, true } if dir == "" { return "", false } // app is a fixed internal constant, never user input, but keep the // read confined to the directory anyway. if strings.ContainsAny(app, `/\`) { return "", false } b, err := os.ReadFile(path.Join(dir, app)) if err != nil { return "", false } key := strings.TrimSpace(string(b)) return key, key != "" } }