diff --git a/internal/tags/provider_musicbrainz.go b/internal/tags/provider_musicbrainz.go index 5a0362c5..b652a917 100644 --- a/internal/tags/provider_musicbrainz.go +++ b/internal/tags/provider_musicbrainz.go @@ -155,6 +155,54 @@ func (p *musicbrainzProvider) TestConnection(ctx context.Context) error { return err } +// ReleaseGroupForRelease names the MusicBrainz release group a release +// belongs to (#5241). Minstrel's albums.mbid is the release id from the tags, +// while Lidarr knows albums only by release group, so re-acquiring a lost +// album needs this translation when its files (and their release-group tag) +// are already gone. +// +// It goes through the registered MusicBrainz provider, so the lookup shares +// that provider's client and 1 req/s limiter with tag enrichment rather than +// doubling Minstrel's request rate, and it respects the provider's on/off +// switch. ErrNotFound: MusicBrainz is switched off, or has no release under +// that id (an id that is already a release group lands here too). +// ErrTransient: retry later. +func ReleaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) { + p, err := ProviderByID("musicbrainz") + if err != nil { + return "", ErrNotFound + } + mb, ok := p.(*musicbrainzProvider) + if !ok { + return "", ErrNotFound + } + return mb.releaseGroupForRelease(ctx, releaseMBID) +} + +// mbReleaseResponse models the subset of /ws/2/release/{mbid}?inc=release-groups +// we read. +type mbReleaseResponse struct { + ReleaseGroup struct { + ID string `json:"id"` + } `json:"release-group"` +} + +func (p *musicbrainzProvider) releaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) { + if !p.enabled.Load() || releaseMBID == "" { + return "", ErrNotFound + } + q := url.Values{"inc": {"release-groups"}, "fmt": {"json"}} + full := mbBaseURL + "/release/" + url.PathEscape(releaseMBID) + "?" + q.Encode() + var resp mbReleaseResponse + if err := p.client.getJSON(ctx, full, &resp); err != nil { + return "", err + } + if resp.ReleaseGroup.ID == "" { + return "", ErrNotFound + } + return resp.ReleaseGroup.ID, nil +} + // normalizeMBTags drops non-positive-vote tags (net-downvoted or zero) and // scales the remaining vote counts into [0,1] relative to the strongest tag // on this recording, so one recording's raw counts don't dominate another's. diff --git a/internal/tags/provider_musicbrainz_test.go b/internal/tags/provider_musicbrainz_test.go index 8af8774c..9de04629 100644 --- a/internal/tags/provider_musicbrainz_test.go +++ b/internal/tags/provider_musicbrainz_test.go @@ -176,3 +176,37 @@ func TestMusicBrainzFetch_EmptyTags(t *testing.T) { t.Errorf("empty tags: err = %v, want ErrNotFound", err) } } + +// #5241: a release id resolves to its release group through +// /release/?inc=release-groups; an unknown id (or one that is already a +// group) and a switched-off source both say ErrNotFound. +func TestMusicBrainzReleaseGroupForRelease(t *testing.T) { + const release, group = "rel-1", "group-1" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/release/"+release { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Not Found"}`)) + return + } + if got := r.URL.Query().Get("inc"); got != "release-groups" { + t.Errorf("inc = %q, want release-groups", got) + } + _, _ = w.Write([]byte(`{"id":"` + release + `","title":"X","release-group":{"id":"` + group + `","title":"X"}}`)) + })) + defer srv.Close() + old := mbBaseURL + mbBaseURL = srv.URL + defer func() { mbBaseURL = old }() + + ctx := context.Background() + got, err := newMBProvider(true).releaseGroupForRelease(ctx, release) + if err != nil || got != group { + t.Fatalf("resolve = (%q, %v), want (%q, nil)", got, err, group) + } + if _, err := newMBProvider(true).releaseGroupForRelease(ctx, "group-1"); !errors.Is(err, ErrNotFound) { + t.Errorf("unknown release: err = %v, want ErrNotFound", err) + } + if _, err := newMBProvider(false).releaseGroupForRelease(ctx, release); !errors.Is(err, ErrNotFound) { + t.Errorf("switched off: err = %v, want ErrNotFound", err) + } +}