package musicbrainz import ( "encoding/xml" "fmt" "io" "net/http" "time" "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 *rateLimiter } // rateLimiter wraps a token-bucket rate limiter for API calls. type rateLimiter struct { // tokens is a channel-based semaphore for rate limiting. // It is filled at a fixed interval by a background goroutine. tokens chan struct{} done chan struct{} } // newRateLimiter creates a rate limiter that allows maxCalls per second. // It immediately fills the bucket and starts a refill goroutine. func newRateLimiter(callsPerSecond int) *rateLimiter { rl := &rateLimiter{ tokens: make(chan struct{}, callsPerSecond), done: make(chan struct{}), } // Fill the bucket initially for i := 0; i < callsPerSecond; i++ { rl.tokens <- struct{}{} } // Refill at the specified interval interval := time.Second / time.Duration(callsPerSecond) go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: select { case rl.tokens <- struct{}{}: default: // bucket full, skip } case <-rl.done: return } } }() return rl } // wait blocks until a token is available or the rate limiter is stopped. func (rl *rateLimiter) wait() { <-rl.tokens } // stop terminates the refill goroutine. func (rl *rateLimiter) stop() { close(rl.done) } // 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 { rl := newRateLimiter(1) // 1 request per second return &MusicBrainzClient{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, userAgent: cfg.UserAgent, baseURL: "https://musicbrainz.org/ws/2", rateLimiter: rl, } } // NewClientWithLimiter creates a MusicBrainzClient with a custom rate limiter. // This is primarily used for testing to inject a mock rate limiter. func NewClientWithLimiter(cfg config.MusicBrainzConfig, rl *rateLimiter) *MusicBrainzClient { return &MusicBrainzClient{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, userAgent: cfg.UserAgent, baseURL: "https://musicbrainz.org/ws/2", rateLimiter: rl, } } // Close cleans up the rate limiter goroutine. func (c *MusicBrainzClient) Close() { c.rateLimiter.stop() } // doGet performs a rate-limited HTTP GET request to the MusicBrainz API. // It sets the proper User-Agent header and returns the response body. func (c *MusicBrainzClient) doGet(path string) ([]byte, error) { c.rateLimiter.wait() req, err := http.NewRequest(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"` } // mbArtistData represents the artist element inside metadata. type mbArtistData struct { ID string `xml:"id,attr"` Name string `xml:"name"` } // mbArtist represents the XML structure of a MusicBrainz artist response. type mbArtist struct { XMLName xml.Name `xml:"metadata"` Artist mbArtistData `xml:"artist"` } // 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 } // ParseArtist parses a MusicBrainz artist XML response into a ParsedArtist struct. func ParseArtist(data []byte) (*ParsedArtist, error) { var artist mbArtist if err := xml.Unmarshal(data, &artist); err != nil { return nil, fmt.Errorf("parse artist XML: %w", err) } return &ParsedArtist{ ID: artist.Artist.ID, Name: artist.Artist.Name, }, nil }