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:
+48
-12
@@ -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";
|
// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all";
|
||||||
// false sends "future". Returns nil on 2xx; typed error otherwise.
|
// 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 {
|
func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
|
||||||
monitor := "future"
|
monitor := "future"
|
||||||
if p.MonitorAll {
|
if p.MonitorAll {
|
||||||
monitor = "all"
|
monitor = "all"
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"foreignArtistId": p.ForeignArtistID,
|
"foreignArtistId": p.ForeignArtistID,
|
||||||
"qualityProfileId": p.QualityProfileID,
|
"artistName": p.ArtistName,
|
||||||
"rootFolderPath": p.RootFolderPath,
|
"qualityProfileId": p.QualityProfileID,
|
||||||
"monitored": true,
|
"metadataProfileId": p.MetadataProfileID,
|
||||||
"monitor": monitor,
|
"rootFolderPath": p.RootFolderPath,
|
||||||
"addOptions": map[string]any{"searchForMissingAlbums": true},
|
"monitored": true,
|
||||||
|
"monitor": monitor,
|
||||||
|
"addOptions": map[string]any{"searchForMissingAlbums": true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
||||||
@@ -290,12 +297,14 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
|
|||||||
// otherwise.
|
// otherwise.
|
||||||
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"foreignAlbumId": p.ForeignAlbumID,
|
"foreignAlbumId": p.ForeignAlbumID,
|
||||||
"foreignArtistId": p.ForeignArtistID,
|
"foreignArtistId": p.ForeignArtistID,
|
||||||
"qualityProfileId": p.QualityProfileID,
|
"artistName": p.ArtistName,
|
||||||
"rootFolderPath": p.RootFolderPath,
|
"qualityProfileId": p.QualityProfileID,
|
||||||
"monitored": true,
|
"metadataProfileId": p.MetadataProfileID,
|
||||||
"addOptions": map[string]any{"searchForNewAlbum": true},
|
"rootFolderPath": p.RootFolderPath,
|
||||||
|
"monitored": true,
|
||||||
|
"addOptions": map[string]any{"searchForNewAlbum": true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
||||||
@@ -308,6 +317,33 @@ func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
|||||||
return nil
|
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
|
// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
|
||||||
// list of quality profiles configured in Lidarr.
|
// list of quality profiles configured in Lidarr.
|
||||||
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
|
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ type QualityProfile struct {
|
|||||||
Name string
|
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.
|
// RootFolder is the dropdown choice in /admin/integrations.
|
||||||
type RootFolder struct {
|
type RootFolder struct {
|
||||||
Path string
|
Path string
|
||||||
@@ -39,19 +49,28 @@ type RootFolder struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist.
|
// 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 {
|
type AddArtistParams struct {
|
||||||
ForeignArtistID string
|
ForeignArtistID string
|
||||||
QualityProfileID int
|
ArtistName string
|
||||||
RootFolderPath string
|
QualityProfileID int
|
||||||
MonitorAll bool // true => monitor="all"; false => "future"
|
MetadataProfileID int
|
||||||
|
RootFolderPath string
|
||||||
|
MonitorAll bool // true => monitor="all"; false => "future"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album.
|
// 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 {
|
type AddAlbumParams struct {
|
||||||
ForeignAlbumID string
|
ForeignAlbumID string
|
||||||
ForeignArtistID string // Lidarr requires the artist's foreign id too
|
ForeignArtistID string // Lidarr requires the artist's foreign id too
|
||||||
QualityProfileID int
|
ArtistName string
|
||||||
RootFolderPath string
|
QualityProfileID int
|
||||||
|
MetadataProfileID int
|
||||||
|
RootFolderPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PingResult is the response shape from GET /api/v1/system/status.
|
// PingResult is the response shape from GET /api/v1/system/status.
|
||||||
|
|||||||
@@ -178,10 +178,31 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg
|
|||||||
return dbq.LidarrRequest{}, ErrDefaultsIncomplete
|
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 {
|
switch row.Kind {
|
||||||
case dbq.LidarrRequestKindArtist:
|
case dbq.LidarrRequestKindArtist:
|
||||||
err = client.AddArtist(ctx, lidarr.AddArtistParams{
|
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:
|
case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
|
||||||
// Track-kind requests promote to album-add; the spec is explicit.
|
// 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
|
albumMBID = *row.LidarrAlbumMbid
|
||||||
}
|
}
|
||||||
err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
|
err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
|
||||||
ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid,
|
ForeignAlbumID: albumMBID,
|
||||||
QualityProfileID: qp, RootFolderPath: rf,
|
ForeignArtistID: row.LidarrArtistMbid,
|
||||||
|
ArtistName: row.ArtistName,
|
||||||
|
QualityProfileID: qp,
|
||||||
|
MetadataProfileID: mdProfileID,
|
||||||
|
RootFolderPath: rf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
case 'lidarr_server_error':
|
case 'lidarr_server_error':
|
||||||
return "Lidarr returned an error. Check Lidarr's logs for the cause.";
|
return "Lidarr returned an error. Check Lidarr's logs for the cause.";
|
||||||
case 'lidarr_rejected':
|
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':
|
case 'request_not_pending':
|
||||||
return 'This request is no longer pending.';
|
return 'This request is no longer pending.';
|
||||||
case 'request_not_found':
|
case 'request_not_found':
|
||||||
|
|||||||
Reference in New Issue
Block a user