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

View File

@@ -26,7 +26,7 @@ Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix comman
- Threaded reply helper for command responses - Threaded reply helper for command responses
- Drop Pete-specific bits: image upload, reaction handler, story formatting - Drop Pete-specific bits: image upload, reaction handler, story formatting
- [ ] **Session 3 — *arr clients** (`internal/arr/`) - [x] **Session 3 — *arr clients** (`internal/arr/`)
- One file per service or a single generic client (TBD in session) - One file per service or a single generic client (TBD in session)
- `Search(term) → []Result` and `Add(result) → error` - `Search(term) → []Result` and `Add(result) → error`
- Radarr: `/api/v3/movie/lookup` + POST `/api/v3/movie` - Radarr: `/api/v3/movie/lookup` + POST `/api/v3/movie`

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)
}

57
internal/arr/lidarr.go Normal file
View File

@@ -0,0 +1,57 @@
package arr
import (
"context"
"fmt"
"bellhop/internal/config"
)
// Lidarr drives a Lidarr v1 instance.
type Lidarr struct {
h *httpClient
qualityProfileID int
metadataProfileID int
rootFolder string
}
func NewLidarr(cfg *config.ArrConfig) *Lidarr {
return &Lidarr{
h: newHTTP(cfg.URL, cfg.APIKey),
qualityProfileID: cfg.QualityProfileID,
metadataProfileID: cfg.MetadataProfileID,
rootFolder: cfg.RootFolder,
}
}
func (l *Lidarr) Search(ctx context.Context, term string) ([]Result, error) {
body, err := l.h.get(ctx, "/api/v1/artist/lookup", map[string]string{"term": term})
if err != nil {
return nil, err
}
// Lidarr's lookup uses "artistName" instead of "title".
return parseLookup(body, "artistName")
}
func (l *Lidarr) Add(ctx context.Context, res Result) error {
if len(res.raw) == 0 {
return fmt.Errorf("lidarr: empty result")
}
body, err := buildAddBody(res.raw, map[string]any{
"qualityProfileId": l.qualityProfileID,
"metadataProfileId": l.metadataProfileID,
"rootFolderPath": l.rootFolder,
"monitored": true,
"addOptions": map[string]any{
"monitor": "all",
"searchForMissingAlbums": true,
},
})
if err != nil {
return err
}
_, err = l.h.post(ctx, "/api/v1/artist", body)
return err
}
var _ Client = (*Lidarr)(nil)

View File

@@ -0,0 +1,69 @@
package arr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"bellhop/internal/config"
)
func TestLidarrSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/artist/lookup" {
t.Errorf("path = %q", r.URL.Path)
}
if r.URL.Query().Get("term") != "radiohead" {
t.Errorf("term = %q", r.URL.Query().Get("term"))
}
_, _ = io.WriteString(w, `[{"artistName":"Radiohead","foreignArtistId":"a74b1b7f-71a5-4011-9441-d0b5e4122711"}]`)
}))
defer srv.Close()
l := NewLidarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 2, MetadataProfileID: 1, RootFolder: "/music"})
results, err := l.Search(context.Background(), "radiohead")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 1 || results[0].Title != "Radiohead" {
t.Fatalf("unexpected results: %+v", results)
}
}
func TestLidarrAdd(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/artist" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
l := NewLidarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 2, MetadataProfileID: 1, RootFolder: "/music"})
res := makeResult("Radiohead", 0, `{"artistName":"Radiohead","foreignArtistId":"a74b1b7f-71a5-4011-9441-d0b5e4122711"}`)
if err := l.Add(context.Background(), res); err != nil {
t.Fatalf("add: %v", err)
}
if got["qualityProfileId"].(float64) != 2 {
t.Errorf("qualityProfileId = %v", got["qualityProfileId"])
}
if got["metadataProfileId"].(float64) != 1 {
t.Errorf("metadataProfileId = %v", got["metadataProfileId"])
}
if got["rootFolderPath"] != "/music" {
t.Errorf("rootFolderPath = %v", got["rootFolderPath"])
}
if got["foreignArtistId"] != "a74b1b7f-71a5-4011-9441-d0b5e4122711" {
t.Errorf("foreignArtistId not preserved: %v", got["foreignArtistId"])
}
opts := got["addOptions"].(map[string]any)
if opts["searchForMissingAlbums"] != true || opts["monitor"] != "all" {
t.Errorf("addOptions wrong: %v", opts)
}
}

60
internal/arr/radarr.go Normal file
View File

@@ -0,0 +1,60 @@
package arr
import (
"context"
"encoding/json"
"fmt"
"bellhop/internal/config"
)
// Radarr drives a Radarr v3 instance.
type Radarr struct {
h *httpClient
qualityProfileID int
rootFolder string
}
func NewRadarr(cfg *config.ArrConfig) *Radarr {
return &Radarr{
h: newHTTP(cfg.URL, cfg.APIKey),
qualityProfileID: cfg.QualityProfileID,
rootFolder: cfg.RootFolder,
}
}
func (r *Radarr) Search(ctx context.Context, term string) ([]Result, error) {
body, err := r.h.get(ctx, "/api/v3/movie/lookup", map[string]string{"term": term})
if err != nil {
return nil, err
}
return parseLookup(body, "title")
}
func (r *Radarr) Add(ctx context.Context, res Result) error {
if len(res.raw) == 0 {
return fmt.Errorf("radarr: empty result")
}
body, err := buildAddBody(res.raw, map[string]any{
"qualityProfileId": r.qualityProfileID,
"rootFolderPath": r.rootFolder,
"monitored": true,
"addOptions": map[string]any{
"searchForMovie": true,
},
})
if err != nil {
return err
}
_, err = r.h.post(ctx, "/api/v3/movie", body)
return err
}
// compile-time interface check
var _ Client = (*Radarr)(nil)
// makeResult is a tiny helper used by tests in this package to construct a
// Result from arbitrary JSON without going through the network.
func makeResult(title string, year int, raw string) Result {
return Result{Title: title, Year: year, raw: json.RawMessage(raw)}
}

View File

@@ -0,0 +1,96 @@
package arr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"bellhop/internal/config"
)
func TestRadarrSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("api key header = %q, want secret", got)
}
if r.URL.Path != "/api/v3/movie/lookup" {
t.Errorf("path = %q", r.URL.Path)
}
if r.URL.Query().Get("term") != "dune" {
t.Errorf("term = %q", r.URL.Query().Get("term"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `[
{"title":"Dune","year":2021,"tmdbId":438631,"titleSlug":"dune-2021"},
{"title":"Dune: Part Two","year":2024,"tmdbId":693134,"titleSlug":"dune-part-two-2024"}
]`)
}))
defer srv.Close()
r := NewRadarr(&config.ArrConfig{URL: srv.URL, APIKey: "secret", QualityProfileID: 6, RootFolder: "/movies"})
results, err := r.Search(context.Background(), "dune")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 2 {
t.Fatalf("got %d results, want 2", len(results))
}
if results[0].Title != "Dune" || results[0].Year != 2021 {
t.Errorf("first result = %+v", results[0])
}
}
func TestRadarrAdd(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/movie" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":42}`)
}))
defer srv.Close()
r := NewRadarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 6, RootFolder: "/movies"})
res := makeResult("Dune", 2021, `{"title":"Dune","year":2021,"tmdbId":438631,"titleSlug":"dune-2021"}`)
if err := r.Add(context.Background(), res); err != nil {
t.Fatalf("add: %v", err)
}
if got["qualityProfileId"].(float64) != 6 {
t.Errorf("qualityProfileId = %v", got["qualityProfileId"])
}
if got["rootFolderPath"] != "/movies" {
t.Errorf("rootFolderPath = %v", got["rootFolderPath"])
}
if got["monitored"] != true {
t.Errorf("monitored = %v", got["monitored"])
}
if got["tmdbId"].(float64) != 438631 {
t.Errorf("tmdbId not preserved from lookup: %v", got["tmdbId"])
}
opts := got["addOptions"].(map[string]any)
if opts["searchForMovie"] != true {
t.Errorf("addOptions.searchForMovie = %v", opts["searchForMovie"])
}
}
func TestRadarrAddError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"already exists"}`)
}))
defer srv.Close()
r := NewRadarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 1, RootFolder: "/m"})
err := r.Add(context.Background(), makeResult("X", 0, `{"title":"X"}`))
if err == nil {
t.Fatal("expected error, got nil")
}
}

55
internal/arr/sonarr.go Normal file
View File

@@ -0,0 +1,55 @@
package arr
import (
"context"
"fmt"
"bellhop/internal/config"
)
// Sonarr drives a Sonarr v3/v4 instance.
type Sonarr struct {
h *httpClient
qualityProfileID int
rootFolder string
}
func NewSonarr(cfg *config.ArrConfig) *Sonarr {
return &Sonarr{
h: newHTTP(cfg.URL, cfg.APIKey),
qualityProfileID: cfg.QualityProfileID,
rootFolder: cfg.RootFolder,
}
}
func (s *Sonarr) Search(ctx context.Context, term string) ([]Result, error) {
body, err := s.h.get(ctx, "/api/v3/series/lookup", map[string]string{"term": term})
if err != nil {
return nil, err
}
return parseLookup(body, "title")
}
func (s *Sonarr) Add(ctx context.Context, res Result) error {
if len(res.raw) == 0 {
return fmt.Errorf("sonarr: empty result")
}
body, err := buildAddBody(res.raw, map[string]any{
"qualityProfileId": s.qualityProfileID,
"rootFolderPath": s.rootFolder,
"monitored": true,
"seasonFolder": true,
"addOptions": map[string]any{
"monitor": "all",
"searchForMissingEpisodes": true,
"searchForCutoffUnmetEpisodes": false,
},
})
if err != nil {
return err
}
_, err = s.h.post(ctx, "/api/v3/series", body)
return err
}
var _ Client = (*Sonarr)(nil)

View File

@@ -0,0 +1,66 @@
package arr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"bellhop/internal/config"
)
func TestSonarrSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" {
t.Errorf("path = %q", r.URL.Path)
}
if r.URL.Query().Get("term") != "severance" {
t.Errorf("term = %q", r.URL.Query().Get("term"))
}
_, _ = io.WriteString(w, `[{"title":"Severance","year":2022,"tvdbId":371980}]`)
}))
defer srv.Close()
s := NewSonarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 4, RootFolder: "/tv"})
results, err := s.Search(context.Background(), "severance")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 1 || results[0].Title != "Severance" || results[0].Year != 2022 {
t.Fatalf("unexpected results: %+v", results)
}
}
func TestSonarrAdd(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/series" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
s := NewSonarr(&config.ArrConfig{URL: srv.URL, APIKey: "k", QualityProfileID: 4, RootFolder: "/tv"})
res := makeResult("Severance", 2022, `{"title":"Severance","year":2022,"tvdbId":371980}`)
if err := s.Add(context.Background(), res); err != nil {
t.Fatalf("add: %v", err)
}
if got["qualityProfileId"].(float64) != 4 {
t.Errorf("qualityProfileId = %v", got["qualityProfileId"])
}
if got["rootFolderPath"] != "/tv" {
t.Errorf("rootFolderPath = %v", got["rootFolderPath"])
}
if got["monitored"] != true || got["seasonFolder"] != true {
t.Errorf("monitored/seasonFolder wrong: %v", got)
}
opts := got["addOptions"].(map[string]any)
if opts["monitor"] != "all" || opts["searchForMissingEpisodes"] != true {
t.Errorf("addOptions wrong: %v", opts)
}
}

View File

@@ -40,6 +40,8 @@ type ArrConfig struct {
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
QualityProfileID int `yaml:"quality_profile_id"` QualityProfileID int `yaml:"quality_profile_id"`
RootFolder string `yaml:"root_folder"` RootFolder string `yaml:"root_folder"`
// MetadataProfileID is required by Lidarr; ignored by Radarr/Sonarr.
MetadataProfileID int `yaml:"metadata_profile_id"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@@ -108,6 +110,9 @@ func (c *Config) validate() error {
return fmt.Errorf("services.%s.quality_profile_id is required when service is set", name) return fmt.Errorf("services.%s.quality_profile_id is required when service is set", name)
} }
} }
if c.Services.Lidarr != nil && c.Services.Lidarr.MetadataProfileID == 0 {
return fmt.Errorf("services.lidarr.metadata_profile_id is required when lidarr is set")
}
return nil return nil
} }