From 206b378d936834c7a66e13d298f3841d468bfeff Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 24 May 2026 20:22:50 -0700 Subject: [PATCH] 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. --- SESSION_PLAN.md | 2 +- internal/arr/arr.go | 128 ++++++++++++++++++++++++++++++++++++ internal/arr/lidarr.go | 57 ++++++++++++++++ internal/arr/lidarr_test.go | 69 +++++++++++++++++++ internal/arr/radarr.go | 60 +++++++++++++++++ internal/arr/radarr_test.go | 96 +++++++++++++++++++++++++++ internal/arr/sonarr.go | 55 ++++++++++++++++ internal/arr/sonarr_test.go | 66 +++++++++++++++++++ internal/config/config.go | 5 ++ 9 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 internal/arr/arr.go create mode 100644 internal/arr/lidarr.go create mode 100644 internal/arr/lidarr_test.go create mode 100644 internal/arr/radarr.go create mode 100644 internal/arr/radarr_test.go create mode 100644 internal/arr/sonarr.go create mode 100644 internal/arr/sonarr_test.go diff --git a/SESSION_PLAN.md b/SESSION_PLAN.md index b69806c..e5f663b 100644 --- a/SESSION_PLAN.md +++ b/SESSION_PLAN.md @@ -26,7 +26,7 @@ Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix comman - Threaded reply helper for command responses - 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) - `Search(term) → []Result` and `Add(result) → error` - Radarr: `/api/v3/movie/lookup` + POST `/api/v3/movie` diff --git a/internal/arr/arr.go b/internal/arr/arr.go new file mode 100644 index 0000000..48b185f --- /dev/null +++ b/internal/arr/arr.go @@ -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) +} diff --git a/internal/arr/lidarr.go b/internal/arr/lidarr.go new file mode 100644 index 0000000..41f706c --- /dev/null +++ b/internal/arr/lidarr.go @@ -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) diff --git a/internal/arr/lidarr_test.go b/internal/arr/lidarr_test.go new file mode 100644 index 0000000..0283fde --- /dev/null +++ b/internal/arr/lidarr_test.go @@ -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) + } +} diff --git a/internal/arr/radarr.go b/internal/arr/radarr.go new file mode 100644 index 0000000..b1be3cb --- /dev/null +++ b/internal/arr/radarr.go @@ -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)} +} diff --git a/internal/arr/radarr_test.go b/internal/arr/radarr_test.go new file mode 100644 index 0000000..ad84725 --- /dev/null +++ b/internal/arr/radarr_test.go @@ -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") + } +} diff --git a/internal/arr/sonarr.go b/internal/arr/sonarr.go new file mode 100644 index 0000000..72335b6 --- /dev/null +++ b/internal/arr/sonarr.go @@ -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) diff --git a/internal/arr/sonarr_test.go b/internal/arr/sonarr_test.go new file mode 100644 index 0000000..9128a04 --- /dev/null +++ b/internal/arr/sonarr_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index fb90fc1..f95118f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,8 @@ type ArrConfig struct { APIKey string `yaml:"api_key"` QualityProfileID int `yaml:"quality_profile_id"` 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) { @@ -108,6 +110,9 @@ func (c *Config) validate() error { 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 }