- Add golang.org/x/time/rate dependency for token-bucket rate limiting - Replace custom channel-based rate limiter with rate.NewLimiter(1, 1) - Add context.Context support to doGet for cancellation - Add cached_at column to external_releases via migration 005 - Implement cache hit/miss queries with TTL-based filtering - Add CacheStats type for tracking cached RGIDs - Update ExternalRelease struct with CachedAt field - Add rate limiting tests (1 req/sec enforcement, burst behavior) - Add cache tests (hit, miss, expired, mixed, empty artist) - Update migration count test for new migration
177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
package musicbrainz
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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),
|
|
}
|
|
}
|
|
|
|
// 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 *rate.Limiter) *MusicBrainzClient {
|
|
return &MusicBrainzClient{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
userAgent: cfg.UserAgent,
|
|
baseURL: "https://musicbrainz.org/ws/2",
|
|
rateLimiter: rl,
|
|
}
|
|
}
|
|
|
|
// Close is a no-op for the x/time/rate-based client (the limiter does not
|
|
// spawn goroutines), but retained for API compatibility.
|
|
func (c *MusicBrainzClient) Close() {}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|