Files
minstrel/internal/lidarr/client.go
T
bvandeusenandClaude Opus 5.5 670b30c954
release / web (push) Successful in 1m42s
release / go (push) Successful in 2m7s
release / govulncheck (push) Successful in 37s
release / integration (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m14s
release / Build signed APK (releases and dev) (push) Canceled after 4m42s
fix(lidarr): add an album as the looked-up resource with its artist nested (#5234)
Lidarr's POST /api/v1/album validates `artist` as a nested resource
(AlbumController: RuleFor(s => s.Artist).NotNull()), so the flat payload
we sent was refused with "'Artist' must not be empty" every time. The
album add has never worked against a real Lidarr; approved album and
track requests sat in the reconciler retrying every 5 minutes.

AddAlbum now does what Lidarr's own add-album UI does (getNewAlbum /
getNewArtist): look the album up by MBID (album/lookup?term=lidarr:<mbid>),
then POST that resource back with monitored + searchForNewAlbum. When
Lidarr doesn't have the artist yet, the nested artist gets the request's
quality/metadata profile and root folder, monitors this album only
(monitor "none" + albumsToMonitor, which AlbumMonitoredService prefers)
and no future releases. An artist Lidarr already has is left as it is.
An MBID Lidarr's metadata doesn't know is ErrNotFound with no POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:55:41 -04:00

501 lines
16 KiB
Go

package lidarr
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
type Client struct {
BaseURL string
APIKey string
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) {
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
if q != nil {
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
_ = resp.Body.Close()
return nil, ErrAuthFailed
}
if resp.StatusCode >= 500 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
}
if resp.StatusCode >= 400 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
}
return resp, nil
}
// readBodySnippet reads up to 512 bytes from an error response body so the
// wrapped error message can include Lidarr's actual complaint (e.g.
// `{"errorMessage":"Artist already exists in your library"}`). Without this,
// callers see only the status-code bucket and can't diagnose without
// cross-referencing Lidarr's own logs.
func readBodySnippet(r io.Reader) string {
buf, _ := io.ReadAll(io.LimitReader(r, 512))
return strings.TrimSpace(string(buf))
}
// post is the shared POST helper. body must be marshaled JSON. It maps HTTP
// status codes to typed errors; the caller is responsible for closing the
// returned body.
func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
return c.bodyRequest(ctx, http.MethodPost, path, body)
}
// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() —
// same status-code mapping, same caller-closes-body contract.
func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) {
return c.bodyRequest(ctx, http.MethodPut, path, body)
}
// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle
// is PUT-shaped, so factoring it out keeps the post/put helpers from
// duplicating the status-code mapping.
func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
_ = resp.Body.Close()
return nil, ErrAuthFailed
}
if resp.StatusCode >= 500 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
}
if resp.StatusCode >= 400 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
}
return resp, nil
}
// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized
// LookupResults; Secondary is "{first genre} · {albumCount} albums" with
// missing parts skipped.
func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
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 []lidarrImage `json:"images"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
secondary := ""
if len(r.Genres) > 0 {
secondary = r.Genres[0]
}
if r.AlbumCount > 0 {
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.AlbumCount) + " albums"
}
out = append(out, LookupResult{
MBID: r.ForeignArtistID,
Name: r.ArtistName,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
})
}
return out, nil
}
// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks".
func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
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)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
year := ""
if len(r.ReleaseDate) >= 4 {
year = r.ReleaseDate[:4]
}
secondary := ""
if r.ArtistName != "" {
secondary = r.ArtistName
}
if year != "" {
if secondary != "" {
secondary += " · "
}
secondary += year
}
if r.TrackCount > 0 {
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.TrackCount) + " tracks"
}
out = append(out, LookupResult{
MBID: r.ForeignAlbumID,
ArtistMBID: r.ForeignArtistID,
Name: r.Title,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
})
}
return out, nil
}
// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is
// per-album under the hood. Secondary is "{albumTitle} · {artistName}".
func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
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)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
secondary := r.AlbumTitle
if r.ArtistName != "" {
if secondary != "" {
secondary += " · "
}
secondary += r.ArtistName
}
out = append(out, LookupResult{
MBID: r.ForeignTrackID,
ArtistMBID: r.ForeignArtistID,
AlbumMBID: r.ForeignAlbumID,
Name: r.Title,
Secondary: secondary,
})
}
return out, nil
}
// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all";
// false sends "future". Returns nil on 2xx; typed error otherwise.
//
// All four of artistName, foreignArtistId, qualityProfileId, and
// metadataProfileId are required by Lidarr. Omitting any one produces a
// 400 with field-level validation messages (e.g. "'Metadata Profile Id'
// must be greater than '0'").
func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
monitor := "future"
if p.MonitorAll {
monitor = "all"
}
body, err := json.Marshal(map[string]any{
"foreignArtistId": p.ForeignArtistID,
"artistName": p.ArtistName,
"qualityProfileId": p.QualityProfileID,
"metadataProfileId": p.MetadataProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"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 {
if isAlreadyExists(err) {
return ErrAlreadyExists
}
return err
}
_ = resp.Body.Close()
return nil
}
// isAlreadyExists detects Lidarr's 400 "already in your library" rejection
// from an add endpoint. The post() helper buckets it as ErrLookupFailed
// but embeds Lidarr's errorMessage body snippet; matching the phrase lets
// the idempotent retry path treat a re-add as success instead of error.
func isAlreadyExists(err error) bool {
if !errors.Is(err, ErrLookupFailed) {
return false
}
m := strings.ToLower(err.Error())
return strings.Contains(m, "already exists") ||
strings.Contains(m, "already been added") ||
strings.Contains(m, "already in your library")
}
// AddAlbum adds one album to Lidarr the way Lidarr's own UI does: look the
// album up by MBID, then POST that album resource back with `monitored`,
// `addOptions` and — when Lidarr doesn't have the artist yet — the
// artist's profiles, root folder and monitoring filled in.
//
// POST /api/v1/album validates `artist` as a nested resource (#5234: a
// flat payload is refused with "'Artist' must not be empty"), and the
// lookup is what supplies it: the existing Lidarr artist when there is one,
// otherwise a new one carrying its metadata. A new artist monitors only
// this album (monitor "none" with albumsToMonitor, which Lidarr applies in
// preference to the monitor type) and no future releases, so approving one
// album never subscribes the library to the artist's whole catalogue.
//
// Returns nil on 2xx, ErrAlreadyExists when Lidarr already has the album,
// ErrNotFound when Lidarr's metadata has no album under that MBID.
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
album, err := c.lookupAlbumResource(ctx, p.ForeignAlbumID)
if err != nil {
return err
}
album["monitored"] = true
album["addOptions"] = map[string]any{"searchForNewAlbum": true}
artist, _ := album["artist"].(map[string]any)
if artist == nil {
artist = map[string]any{
"foreignArtistId": p.ForeignArtistID,
"artistName": p.ArtistName,
}
album["artist"] = artist
}
if id, _ := artist["id"].(float64); id == 0 {
artist["qualityProfileId"] = p.QualityProfileID
artist["metadataProfileId"] = p.MetadataProfileID
artist["rootFolderPath"] = p.RootFolderPath
artist["monitored"] = true
artist["monitorNewItems"] = "none"
artist["addOptions"] = map[string]any{
"monitor": "none",
"albumsToMonitor": []string{p.ForeignAlbumID},
"monitored": true,
"searchForMissingAlbums": false,
}
}
body, err := json.Marshal(album)
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/album", body)
if err != nil {
if isAlreadyExists(err) {
return ErrAlreadyExists
}
return err
}
_ = resp.Body.Close()
return nil
}
// ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the
// list of metadata profiles configured in Lidarr. The Approve flow uses
// the first profile as a default when the operator hasn't pinned one.
func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) {
resp, err := c.get(ctx, "/api/v1/metadataprofile", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]MetadataProfile, len(raw))
for i, r := range raw {
out[i] = MetadataProfile{ID: r.ID, Name: r.Name}
}
return out, nil
}
// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
// list of quality profiles configured in Lidarr.
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]QualityProfile, len(raw))
for i, r := range raw {
out[i] = QualityProfile{ID: r.ID, Name: r.Name}
}
return out, nil
}
// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root
// folders configured in Lidarr.
func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"freeSpace"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]RootFolder, len(raw))
for i, r := range raw {
out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
}
return out, nil
}
// Ping hits GET /api/v1/system/status to verify connectivity and returns
// the Lidarr version string.
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
resp, err := c.get(ctx, "/api/v1/system/status", nil)
if err != nil {
return PingResult{}, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
return PingResult{Version: raw.Version}, nil
}
// pickPosterImage returns the remote URL (preferred) or local URL for the
// first image with coverType=="poster". Returns "" when none match.
func pickPosterImage(imgs []lidarrImage) string {
for _, img := range imgs {
if img.CoverType == "poster" {
if img.RemoteURL != "" {
return img.RemoteURL
}
return img.URL
}
}
return ""
}