- Fix artist-ID namespace mismatch in MusicBrainz provider: SyncArtistDiscography now stores the canonical Navidrome artist ID (artist_settings.id) as external_releases.artist_id instead of the MusicBrainz MBID. Previously the MBID was stored, which violated the FK to artist_settings and broke the scanner join (local_albums.artist_id is the Navidrome ID), causing every external release to be falsely reported as missing and the sync insert to fail at runtime. getArtistFilterOptions now also resolves by the Navidrome ID. - Resolve threshold in FindMissingReleases so the exported primitive honors the same zero-means-default contract as ScanArtist/ScanAll. - Remove dead maxLen==0 guard in scanner.Similarity. - Inline trivial buildPath helper; drop unused url import in client.go. - Replace hand-rolled itoa with strconv.Itoa in tests. - Rewrite SyncArtistDiscography tests to seed artist_settings with the Navidrome ID (tests previously seeded the MBID to mask the FK mismatch). - Fix TestFuzzySmoke to exercise the real dependency (fuzzy.LevenshteinDistance / scanner.Similarity) instead of an unused API. - Fix TestAppRun_ScanLogsMissingReleases to run the scan against a live context and assert the missing release is found. - Document cached_at column in Specification.md and note startup scan / required musicbrainz.user_agent in README. - Stop tracking .serena/ tooling config; add it to .gitignore.
136 lines
4.2 KiB
Go
136 lines
4.2 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),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|