feat(tags): resolve a MusicBrainz release to its release group (M483 #5243)

ReleaseGroupForRelease asks /ws/2/release/<id>?inc=release-groups through
the registered MusicBrainz provider, so it shares that provider's client
and 1 req/s limiter with tag enrichment and respects its on/off switch.
ErrNotFound when switched off or MusicBrainz has no such release (an id
that is already a release group included); ErrTransient to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 11:34:04 -04:00
co-authored by Claude Opus 5.5
parent 5c19a916ba
commit 2b4e274b12
2 changed files with 82 additions and 0 deletions
+48
View File
@@ -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.
@@ -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/<id>?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)
}
}