fix(lidarr): address review findings on HTTP client

- Add ArtistMBID/AlbumMBID to LookupResult so handlers can build the
  full lidarr_requests row from album/track lookups (parent MBID is
  needed for the AddAlbum API call and for the schema's NOT NULL
  lidarr_artist_mbid)
- Trim trailing slash on BaseURL before joining paths to avoid
  double-slash URLs when operators enter "http://lidarr.lan/"
- Check json.Marshal errors in AddArtist/AddAlbum
- Extract lidarrImage as a named unexported type instead of repeating
  the anonymous struct three times
- Add NewClient(baseURL, apiKey) constructor with 30s default timeout
- Add ErrLookupFailed test coverage for the 4xx-non-auth branch on
  both get and post helpers
- Rename TestListRootFolders_BadJSON -> _NullBody (it tests null,
  not malformed); add a real malformed-JSON test
- Defensive separator guard on LookupAlbum.Secondary so an empty
  ArtistName doesn't produce a leading " · "

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 16:26:23 -04:00
parent a43fa09a04
commit ebff8f69a0
5 changed files with 161 additions and 64 deletions
+64 -42
View File
@@ -9,6 +9,8 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
@@ -19,6 +21,17 @@ type Client struct {
HTTP *http.Client
}
// NewClient builds a Client with sensible HTTP defaults (30s timeout,
// matching the pattern in internal/scrobble/listenbrainz). Call sites
// that need a custom client can construct the Client struct directly.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// get executes an authenticated GET request and maps HTTP status codes to
// typed errors. The caller is responsible for closing the returned body.
func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
@@ -26,7 +39,7 @@ func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Resp
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = u.Path + path
u.Path = strings.TrimRight(u.Path, "/") + path
if q != nil {
u.RawQuery = q.Encode()
}
@@ -62,7 +75,7 @@ func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Resp
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = u.Path + path
u.Path = strings.TrimRight(u.Path, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
@@ -102,15 +115,11 @@ func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult,
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignArtistID string `json:"foreignArtistId"`
ArtistName string `json:"artistName"`
Genres []string `json:"genres"`
AlbumCount int `json:"albumCount"`
Images []struct {
CoverType string `json:"coverType"`
RemoteURL string `json:"remoteUrl"`
URL string `json:"url"`
} `json:"images"`
ForeignArtistID string `json:"foreignArtistId"`
ArtistName string `json:"artistName"`
Genres []string `json:"genres"`
AlbumCount int `json:"albumCount"`
Images []lidarrImage `json:"images"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
@@ -150,16 +159,13 @@ func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult,
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignAlbumID string `json:"foreignAlbumId"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ReleaseDate string `json:"releaseDate"`
TrackCount int `json:"trackCount"`
Images []struct {
CoverType string `json:"coverType"`
RemoteURL string `json:"remoteUrl"`
URL string `json:"url"`
} `json:"images"`
ForeignAlbumID string `json:"foreignAlbumId"`
ForeignArtistID string `json:"foreignArtistId"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ReleaseDate string `json:"releaseDate"`
TrackCount int `json:"trackCount"`
Images []lidarrImage `json:"images"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
@@ -170,18 +176,28 @@ func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult,
if len(r.ReleaseDate) >= 4 {
year = r.ReleaseDate[:4]
}
secondary := r.ArtistName
secondary := ""
if r.ArtistName != "" {
secondary = r.ArtistName
}
if year != "" {
secondary += " · " + year
if secondary != "" {
secondary += " · "
}
secondary += year
}
if r.TrackCount > 0 {
secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks"
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.TrackCount) + " tracks"
}
out = append(out, LookupResult{
MBID: r.ForeignAlbumID,
Name: r.Title,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
MBID: r.ForeignAlbumID,
ArtistMBID: r.ForeignArtistID,
Name: r.Title,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
})
}
return out, nil
@@ -200,10 +216,12 @@ func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult,
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignTrackID string `json:"foreignTrackId"`
Title string `json:"title"`
AlbumTitle string `json:"albumTitle"`
ArtistName string `json:"artistName"`
ForeignTrackID string `json:"foreignTrackId"`
ForeignAlbumID string `json:"foreignAlbumId"`
ForeignArtistID string `json:"foreignArtistId"`
Title string `json:"title"`
AlbumTitle string `json:"albumTitle"`
ArtistName string `json:"artistName"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
@@ -218,9 +236,11 @@ func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult,
secondary += r.ArtistName
}
out = append(out, LookupResult{
MBID: r.ForeignTrackID,
Name: r.Title,
Secondary: secondary,
MBID: r.ForeignTrackID,
ArtistMBID: r.ForeignArtistID,
AlbumMBID: r.ForeignAlbumID,
Name: r.Title,
Secondary: secondary,
})
}
return out, nil
@@ -233,7 +253,7 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
if p.MonitorAll {
monitor = "all"
}
body, _ := json.Marshal(map[string]any{
body, err := json.Marshal(map[string]any{
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
@@ -241,6 +261,9 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
"monitor": monitor,
"addOptions": map[string]any{"searchForMissingAlbums": true},
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/artist", body)
if err != nil {
return err
@@ -252,7 +275,7 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error
// otherwise.
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
body, _ := json.Marshal(map[string]any{
body, err := json.Marshal(map[string]any{
"foreignAlbumId": p.ForeignAlbumID,
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
@@ -260,6 +283,9 @@ func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
"monitored": true,
"addOptions": map[string]any{"searchForNewAlbum": true},
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/album", body)
if err != nil {
return err
@@ -344,11 +370,7 @@ func (c *Client) Ping(ctx context.Context) (PingResult, error) {
// pickPosterImage returns the remote URL (preferred) or local URL for the
// first image with coverType=="poster". Returns "" when none match.
func pickPosterImage(imgs []struct {
CoverType string `json:"coverType"`
RemoteURL string `json:"remoteUrl"`
URL string `json:"url"`
}) string {
func pickPosterImage(imgs []lidarrImage) string {
for _, img := range imgs {
if img.CoverType == "poster" {
if img.RemoteURL != "" {