package musicbrainz import ( "context" "encoding/json" "fmt" "log" "net/url" "naviwatcher/internal/normalize" ) // mbArtistSearchResult models the JSON response of the MusicBrainz artist // search endpoint (/ws/2/artist?query=artist:&fmt=json). Only the // fields we need for MBID resolution are decoded. type mbArtistSearchResult struct { Artists []struct { ID string `json:"id"` Name string `json:"name"` Score int `json:"score"` } `json:"artists"` } // minResolutionScore is the minimum MusicBrainz search score (0-100) we accept // for an MBID resolution. Below this, the best hit is too weak a match to // trust, and caching it would silently pollute an artist's discography with // the wrong MusicBrainz data. const minResolutionScore = 80 // ResolveArtistMBID resolves a MusicBrainz artist ID (MBID) for the given // artist name by querying the MusicBrainz artist search endpoint. It returns // the ID of the highest-scoring matching artist, but only when that artist's // normalized name actually matches the requested name (and its search score is // at or above minResolutionScore). An error is returned if the search yields // no usable match, the response cannot be parsed, or the underlying request // fails. Rejecting a low-confidence hit lets the caller surface the problem // instead of caching a wrong MBID. func (c *MusicBrainzClient) ResolveArtistMBID(ctx context.Context, name string) (string, error) { params := url.Values{} params.Set("query", fmt.Sprintf("artist:%s", name)) params.Set("fmt", "json") path := "/artist?" + params.Encode() body, err := c.doGet(ctx, path) if err != nil { return "", fmt.Errorf("resolve MBID for artist %q: %w", name, err) } var result mbArtistSearchResult if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("parse artist search response for %q: %w", name, err) } if len(result.Artists) == 0 { return "", fmt.Errorf("no MusicBrainz artist found for %q", name) } best := result.Artists[0] if best.Score < minResolutionScore { return "", fmt.Errorf("no confident MusicBrainz match for %q (best candidate %q scored %d, need >= %d)", name, best.Name, best.Score, minResolutionScore) } // Even with a high score, require the normalized name to match, guarding // against score inflation on name collisions (e.g. tribute acts). if normalize.NormalizeArtistName(best.Name) != normalize.NormalizeArtistName(name) { log.Printf("MusicBrainz MBID resolution skipped for %q: best candidate %q did not match by name", name, best.Name) return "", fmt.Errorf("best MusicBrainz candidate %q does not match %q by name", best.Name, name) } return best.ID, nil }