From 7d33166f23841c8df372411a5f7d42546f5710a9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 23:57:39 -0400 Subject: [PATCH] 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) --- internal/lidarr/client.go | 60 +++++++++++++++++----- internal/lidarr/types.go | 35 ++++++++++--- internal/lidarrrequests/service.go | 31 +++++++++-- web/src/routes/admin/requests/+page.svelte | 2 +- 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go index f75399e4..f57ccbca 100644 --- a/internal/lidarr/client.go +++ b/internal/lidarr/client.go @@ -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) { diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go index 8e4c8052..a3775cc5 100644 --- a/internal/lidarr/types.go +++ b/internal/lidarr/types.go @@ -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. diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go index 7f2d9b07..52b1a07a 100644 --- a/internal/lidarrrequests/service.go +++ b/internal/lidarrrequests/service.go @@ -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 { diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index b7d2268b..810af4fb 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -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':