Files
NaviWatcher/internal/musicbrainz/client.go
Vladimir Zagainov a5911c257c fix: address code review findings
Remove dead code: duplicate ExternalRelease/Artist/ParsedArtist structs in
model.go, ParseArtist/mbArtist/mbArtistData in client.go, ArtistTypeFilter
and related filtering functions in api.go, SyncArtistDiscographyWithFilter
in sync.go, and CacheStats/IsArtistCacheValid in cache.go.

Fix bugs: SaveExternalRelease now stores NULL instead of empty string for
zero CachedAt; sync upserts are now transactional with stale release cleanup;
getCachedReleases returns int instead of *CacheStats; doGet uses url.Values
for proper query encoding of MBID.

Fix tests: removed duplicate TestRun_GracefulShutdown, removed dead code
(_ = dbPath) from TestNewApp, fixed assertions in httptest handler goroutine
to avoid data race, increased rate limiter timing tolerance, removed
Client.Close() calls (no-op removed), fixed sync test cache expiry to use
UPDATE instead of 0 TTL races.

Fix formatting: cancel()}() formatting in main.go, error format string in sync.go.
2026-05-26 14:10:22 +03:00

142 lines
4.4 KiB
Go

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()
}