package musicbrainz import ( "context" "encoding/xml" "fmt" "io" "net/http" "net/url" "time" "golang.org/x/time/rate" "naviwatcher/internal/config" ) // MusicBrainzClient wraps net/http.Client with rate limiting and configuration // for the MusicBrainz Web Service API (version 2). type MusicBrainzClient struct { httpClient *http.Client userAgent string baseURL string rateLimiter *rate.Limiter } // NewClient creates a new MusicBrainzClient from the given configuration. // It initializes the HTTP client with a 30-second timeout and sets up // a rate limiter for 1 request per second as required by MusicBrainz policy. func NewClient(cfg config.MusicBrainzConfig) *MusicBrainzClient { return &MusicBrainzClient{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, userAgent: cfg.UserAgent, baseURL: "https://musicbrainz.org/ws/2", rateLimiter: rate.NewLimiter(rate.Limit(1), 1), } } // doGet performs a rate-limited HTTP GET request to the MusicBrainz API. // It blocks until the rate limiter allows the request, then sets the proper // User-Agent header and returns the response body. func (c *MusicBrainzClient) doGet(ctx context.Context, path string) ([]byte, error) { if err := c.rateLimiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limiter wait: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", c.userAgent) req.Header.Set("Accept", "application/xml") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("musicbrainz API returned HTTP %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit if err != nil { return nil, fmt.Errorf("read response body: %w", err) } return body, nil } // mbArtistRef represents the nested artist element inside a release-group. type mbArtistRef struct { ID string `xml:"id,attr"` Name string `xml:"name"` } // mbNameCredit represents the name-credit element inside a release-group. type mbNameCredit struct { Artist mbArtistRef `xml:"artist"` } // mbArtistCredit represents the artist-credit element inside a release-group. type mbArtistCredit struct { NameCredit mbNameCredit `xml:"name-credit"` } // mbReleaseGroup represents the XML structure of a single release-group // in the MusicBrainz release-group list response. type mbReleaseGroup struct { ID string `xml:"id,attr"` Title string `xml:"title"` Type string `xml:"type,attr"` Status string `xml:"status,attr"` ArtistCredit mbArtistCredit `xml:"artist-credit"` ReleaseDate string `xml:"first-release-date"` } // mbReleaseGroupListXML wraps the release-group-list element to properly // capture both child elements and the count attribute. type mbReleaseGroupListXML struct { ReleaseGroups []mbReleaseGroup `xml:"release-group"` Count int `xml:"count,attr"` } // mbReleaseGroupList represents the XML structure of a release-group list response. type mbReleaseGroupList struct { XMLName xml.Name `xml:"metadata"` ReleaseGroupList mbReleaseGroupListXML `xml:"release-group-list"` } // ParseReleaseGroups parses a MusicBrainz release-group list XML response // into a ParsedReleaseGroups struct. func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) { var list mbReleaseGroupList if err := xml.Unmarshal(data, &list); err != nil { return nil, fmt.Errorf("parse release-group XML: %w", err) } result := &ParsedReleaseGroups{ Count: list.ReleaseGroupList.Count, } for _, rg := range list.ReleaseGroupList.ReleaseGroups { result.ReleaseGroups = append(result.ReleaseGroups, ReleaseGroup{ ID: rg.ID, Title: rg.Title, Type: rg.Type, Status: rg.Status, ArtistID: rg.ArtistCredit.NameCredit.Artist.ID, ArtistName: rg.ArtistCredit.NameCredit.Artist.Name, ReleaseDate: rg.ReleaseDate, }) } return result, nil } // buildPath constructs a properly URL-encoded query path for the MusicBrainz API. func buildPath(endpoint string, params url.Values) string { return endpoint + "?" + params.Encode() }