fix(lidarr): include artistName + metadataProfileId on add-artist/add-album

The 'lidarr_rejected' the operator was hitting on Approve was Lidarr 4xx-ing
the POST with two field-validation errors:

  'Metadata Profile Id' must be greater than '0'.
  'Artist Name' must not be empty.

Our payload was missing both. Lidarr's metadata profile is a separate
concept from quality profile (it controls which release types are
tracked: albums, singles, EPs, etc.) and is required by /api/v1/artist
and /api/v1/album. The 'already exists' line in the toast copy was an
incorrect guess at the cause; the real issue was an invalid payload.

Changes:

- internal/lidarr/types.go: AddArtistParams + AddAlbumParams gain
  ArtistName and MetadataProfileID. New MetadataProfile struct mirroring
  QualityProfile.
- internal/lidarr/client.go: AddArtist + AddAlbum payloads include both
  new fields. New ListMetadataProfiles fetches GET /api/v1/metadataprofile.
- internal/lidarrrequests/service.go: Approve fetches the metadata
  profile list and uses the first as a default (Lidarr installs ship
  'Standard' at id=1 OOB, so this works for vanilla setups). Picker
  on /admin/integrations is a follow-up. ArtistName flows from the
  request row.
- web/src/routes/admin/requests/+page.svelte: drop the
  speculative 'usually means already in library' copy on lidarr_rejected;
  point operators at server logs for the field-level reason instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 23:57:39 -04:00
parent 2946ec4222
commit 7d33166f23
4 changed files with 104 additions and 24 deletions
+48 -12
View File
@@ -262,18 +262,25 @@ func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult,
// 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,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"monitor": monitor,
"addOptions": map[string]any{"searchForMissingAlbums": true},
"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)
@@ -290,12 +297,14 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
// otherwise.
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
body, err := json.Marshal(map[string]any{
"foreignAlbumId": p.ForeignAlbumID,
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"addOptions": map[string]any{"searchForNewAlbum": true},
"foreignAlbumId": p.ForeignAlbumID,
"foreignArtistId": p.ForeignArtistID,
"artistName": p.ArtistName,
"qualityProfileId": p.QualityProfileID,
"metadataProfileId": p.MetadataProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"addOptions": map[string]any{"searchForNewAlbum": true},
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
@@ -308,6 +317,33 @@ func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
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) {
+27 -8
View File
@@ -31,6 +31,16 @@ type QualityProfile struct {
Name string
}
// MetadataProfile controls which release types Lidarr tracks for an artist
// (album / single / EP / compilation). It's a separate Lidarr concept from
// quality profile and is required on POST /api/v1/artist — Lidarr 4xx's
// the request with "'Metadata Profile Id' must be greater than '0'" if
// it's missing or zero.
type MetadataProfile struct {
ID int
Name string
}
// RootFolder is the dropdown choice in /admin/integrations.
type RootFolder struct {
Path string
@@ -39,19 +49,28 @@ type RootFolder struct {
}
// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist.
// ArtistName and MetadataProfileID are both required; omitting either
// produces a 400 with field-validation errors.
type AddArtistParams struct {
ForeignArtistID string
QualityProfileID int
RootFolderPath string
MonitorAll bool // true => monitor="all"; false => "future"
ForeignArtistID string
ArtistName string
QualityProfileID int
MetadataProfileID int
RootFolderPath string
MonitorAll bool // true => monitor="all"; false => "future"
}
// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album.
// MetadataProfileID is included for the case where Lidarr has to create
// the parent artist on the fly (when adding an album by an artist not
// yet in the library).
type AddAlbumParams struct {
ForeignAlbumID string
ForeignArtistID string // Lidarr requires the artist's foreign id too
QualityProfileID int
RootFolderPath string
ForeignAlbumID string
ForeignArtistID string // Lidarr requires the artist's foreign id too
ArtistName string
QualityProfileID int
MetadataProfileID int
RootFolderPath string
}
// PingResult is the response shape from GET /api/v1/system/status.
+28 -3
View File
@@ -178,10 +178,31 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg
return dbq.LidarrRequest{}, ErrDefaultsIncomplete
}
// Lidarr requires a metadata profile id on POST /api/v1/artist (and
// on /api/v1/album when it has to create the parent artist). We
// don't store this in lidarr_config yet — fetch the available
// profiles per-Approve and pick the first as a sensible default.
// Lidarr installs ship "Standard" at id=1 OOB, so this works for
// vanilla setups; operators with custom profiles get whichever was
// saved first. A picker on /admin/integrations is a follow-up.
mdProfiles, err := client.ListMetadataProfiles(ctx)
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("approve: list metadata profiles: %w", err)
}
if len(mdProfiles) == 0 {
return dbq.LidarrRequest{}, fmt.Errorf("approve: no metadata profiles configured in lidarr")
}
mdProfileID := mdProfiles[0].ID
switch row.Kind {
case dbq.LidarrRequestKindArtist:
err = client.AddArtist(ctx, lidarr.AddArtistParams{
ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true,
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
MonitorAll: true,
})
case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
// Track-kind requests promote to album-add; the spec is explicit.
@@ -190,8 +211,12 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg
albumMBID = *row.LidarrAlbumMbid
}
err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid,
QualityProfileID: qp, RootFolderPath: rf,
ForeignAlbumID: albumMBID,
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
})
}
if err != nil {
+1 -1
View File
@@ -80,7 +80,7 @@
case 'lidarr_server_error':
return "Lidarr returned an error. Check Lidarr's logs for the cause.";
case 'lidarr_rejected':
return 'Lidarr rejected the request — usually means the artist or album is already in your library.';
return "Lidarr rejected the request. Check the server logs for the field-level reason.";
case 'request_not_pending':
return 'This request is no longer pending.';
case 'request_not_found':