From 6cc9acbc99551502d8d1d987f23f8cb2afd74a22 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 23 Mar 2026 14:03:51 -0400 Subject: [PATCH] =?UTF-8?q?perf(S01/T02):=20Add=20MusicBrainz=20search/loo?= =?UTF-8?q?kup/browse=20client,=20ListenBrain=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/explore/types.go - backend/explore/musicbrainz.go - backend/explore/listenbrainz.go - backend/explore/coverart.go - backend/explore/coverart_test.go --- backend/explore/coverart.go | 18 ++ backend/explore/coverart_test.go | 52 ++++ backend/explore/listenbrainz.go | 207 +++++++++++++++ backend/explore/musicbrainz.go | 428 +++++++++++++++++++++++++++++++ backend/explore/types.go | 83 ++++++ go.mod | 5 +- go.sum | 6 + 7 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 backend/explore/coverart.go create mode 100644 backend/explore/coverart_test.go create mode 100644 backend/explore/listenbrainz.go create mode 100644 backend/explore/musicbrainz.go create mode 100644 backend/explore/types.go diff --git a/backend/explore/coverart.go b/backend/explore/coverart.go new file mode 100644 index 0000000..120c553 --- /dev/null +++ b/backend/explore/coverart.go @@ -0,0 +1,18 @@ +package explore + +import "fmt" + +const coverArtBaseURL = "https://coverartarchive.org/release" + +// CoverArtURL returns the Cover Art Archive URL for the 250px +// front cover of the given release MBID. +func CoverArtURL(releaseMBID string) string { + return fmt.Sprintf("%s/%s/front-250", coverArtBaseURL, releaseMBID) +} + +// CoverArtURLSize returns the Cover Art Archive URL for the front +// cover of the given release MBID at the specified pixel size. +// Common sizes are 250, 500, and 1200. +func CoverArtURLSize(releaseMBID string, size int) string { + return fmt.Sprintf("%s/%s/front-%d", coverArtBaseURL, releaseMBID, size) +} diff --git a/backend/explore/coverart_test.go b/backend/explore/coverart_test.go new file mode 100644 index 0000000..41095bb --- /dev/null +++ b/backend/explore/coverart_test.go @@ -0,0 +1,52 @@ +package explore_test + +import ( + "testing" + + "yellowjacket/backend/explore" +) + +func TestCoverArtURL(t *testing.T) { + t.Parallel() + + mbid := "76df3287-6cda-33eb-8e9a-044b5e15c37c" + + got := explore.CoverArtURL(mbid) + want := "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-250" + + if got != want { + t.Errorf("CoverArtURL(%q) = %q, want %q", mbid, got, want) + } +} + +func TestCoverArtURLSize(t *testing.T) { + t.Parallel() + + mbid := "76df3287-6cda-33eb-8e9a-044b5e15c37c" + + tests := []struct { + size int + want string + }{ + { + 250, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-250", + }, + { + 500, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-500", + }, + { + 1200, + "https://coverartarchive.org/release/76df3287-6cda-33eb-8e9a-044b5e15c37c/front-1200", + }, + } + + for _, tt := range tests { + got := explore.CoverArtURLSize(mbid, tt.size) + if got != tt.want { + t.Errorf("CoverArtURLSize(%q, %d) = %q, want %q", + mbid, tt.size, got, tt.want) + } + } +} diff --git a/backend/explore/listenbrainz.go b/backend/explore/listenbrainz.go new file mode 100644 index 0000000..498f63f --- /dev/null +++ b/backend/explore/listenbrainz.go @@ -0,0 +1,207 @@ +package explore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +const ( + listenBrainzBaseURL = "https://api.listenbrainz.org" + lbUserAgent = "YellowJacket/dev" +) + +// ErrListenBrainzHTTP is returned when the ListenBrainz API +// responds with a non-2xx status code. +var ErrListenBrainzHTTP = errors.New("listenbrainz HTTP error") + +// ListenBrainzClient is a thin HTTP client for the ListenBrainz +// popularity and labs APIs. All requests are rate-limited via the +// shared RateLimiter and cached via the shared Cache. +type ListenBrainzClient struct { + http *http.Client + limiter *RateLimiter + cache *Cache + logger *slog.Logger +} + +// NewListenBrainzClient creates a ListenBrainz API client. +func NewListenBrainzClient( + limiter *RateLimiter, + cache *Cache, + logger *slog.Logger, +) *ListenBrainzClient { + return &ListenBrainzClient{ + http: &http.Client{Timeout: 30 * time.Second}, + limiter: limiter, + cache: cache, + logger: logger, + } +} + +// TopRecordingsForArtist returns the most-listened recordings for +// the artist identified by artistMBID. +func (c *ListenBrainzClient) TopRecordingsForArtist( + ctx context.Context, artistMBID string, +) ([]LBTopRecording, error) { + url := fmt.Sprintf( + "%s/1/popularity/top-recordings-for-artist/%s", + listenBrainzBaseURL, + artistMBID, + ) + cacheKey := "lb:top-recordings:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []LBTopRecording + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("listenbrainz top recordings: %w", err) + } + + // The API returns an array directly. + var out []LBTopRecording + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("listenbrainz top recordings unmarshal: %w", err) + } + + c.cacheJSON(cacheKey, out, cacheTTLSearch, artistMBID, "artist") + + return out, nil +} + +// SimilarArtists returns artists similar to the one identified by +// artistMBID, using the ListenBrainz labs API. Returns nil, nil +// if the endpoint is unavailable (labs API may be unstable). +func (c *ListenBrainzClient) SimilarArtists( + ctx context.Context, artistMBID string, +) ([]LBSimilarArtist, error) { + url := fmt.Sprintf( + "%s/1/explore/similar-artists/%s", + listenBrainzBaseURL, + artistMBID, + ) + cacheKey := "lb:similar-artists:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []LBSimilarArtist + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + body, err := c.doGet(ctx, url) + if err != nil { + // Labs API may be unstable — log and return empty. + c.logger.Warn("listenbrainz similar artists unavailable", + "artistMBID", artistMBID, + "err", err, + ) + + return nil, nil //nolint:nilnil // graceful degradation for unstable endpoint + } + + var out []LBSimilarArtist + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("listenbrainz similar artists unmarshal: %w", err) + } + + c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist") + + return out, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// doGet performs a rate-limited GET request and returns the response +// body. Non-2xx status codes are returned as errors. +func (c *ListenBrainzClient) doGet( + ctx context.Context, url string, +) ([]byte, error) { + c.logger.Debug("listenbrainz rate limiter wait", "url", url) + + if err := c.limiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limiter: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", lbUserAgent) + + c.logger.Info("listenbrainz request", + "method", http.MethodGet, + "url", url, + ) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + c.logger.Info("listenbrainz response", + "url", url, + "status", resp.StatusCode, + ) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf( + "%w: %d %s", ErrListenBrainzHTTP, resp.StatusCode, truncateBody(body), + ) + } + + return body, nil +} + +// cacheJSON marshals v to JSON and stores it in the cache. +func (c *ListenBrainzClient) cacheJSON( + key string, + v any, + ttl time.Duration, + mbid string, + entityType string, +) { + data, err := json.Marshal(v) + if err != nil { + c.logger.Warn("listenbrainz cache marshal error", + "key", key, + "err", err, + ) + + return + } + + c.cache.Set(key, data, ttl, mbid, entityType) +} + +// truncateBody returns the first 200 bytes of an error response +// for diagnostic logging. +func truncateBody(body []byte) string { + const maxLen = 200 + + if len(body) <= maxLen { + return string(body) + } + + return string(body[:maxLen]) + "…" +} diff --git a/backend/explore/musicbrainz.go b/backend/explore/musicbrainz.go new file mode 100644 index 0000000..6dcf4a9 --- /dev/null +++ b/backend/explore/musicbrainz.go @@ -0,0 +1,428 @@ +package explore + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "go.uploadedlobster.com/mbtypes" + "go.uploadedlobster.com/musicbrainzws2" +) + +const ( + // cacheTTLSearch is the TTL for search results (results may shift). + cacheTTLSearch = 24 * time.Hour + // cacheTTLEntity is the TTL for lookup/browse results (entity data + // changes rarely). + cacheTTLEntity = 7 * 24 * time.Hour +) + +// MusicBrainzClient wraps the musicbrainzws2 library with a local +// response cache. Every API call checks the cache first and stores +// successful responses for future hits. +// +// The underlying musicbrainzws2.Client handles MusicBrainz-specific +// rate limiting via retries on HTTP 429, so we do not use the +// RateLimiter from this package (that is reserved for ListenBrainz). +type MusicBrainzClient struct { + mb *musicbrainzws2.Client + cache *Cache + logger *slog.Logger +} + +// NewMusicBrainzClient creates a MusicBrainz API client that caches +// responses in the given Cache. +func NewMusicBrainzClient(cache *Cache, logger *slog.Logger) *MusicBrainzClient { + mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{ + Name: "YellowJacket", + Version: "dev", + URL: "https://github.com/yellowjacket", + }) + + return &MusicBrainzClient{ + mb: mb, + cache: cache, + logger: logger, + } +} + +// Close releases resources held by the underlying HTTP client. +func (c *MusicBrainzClient) Close() error { + return c.mb.Close() +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +// SearchArtists queries MusicBrainz for artists matching the given +// query string. Results are cached for 1 day. +func (c *MusicBrainzClient) SearchArtists( + ctx context.Context, query string, limit int, +) ([]MBArtist, error) { + cacheKey := "mb:search:artist:" + query + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBArtist + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + c.logger.Info("musicbrainz search artists", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchArtists(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, err + } + + out := convertArtists(result.Artists) + + c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + + return out, nil +} + +// SearchReleaseGroups queries MusicBrainz for release groups +// matching the given query string. +func (c *MusicBrainzClient) SearchReleaseGroups( + ctx context.Context, query string, limit int, +) ([]MBReleaseGroup, error) { + cacheKey := "mb:search:release-group:" + query + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + c.logger.Info("musicbrainz search release groups", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchReleaseGroups(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, err + } + + out := convertReleaseGroups(result.ReleaseGroups) + + c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + + return out, nil +} + +// SearchRecordings queries MusicBrainz for recordings matching the +// given query string. +func (c *MusicBrainzClient) SearchRecordings( + ctx context.Context, query string, limit int, +) ([]MBRecording, error) { + cacheKey := "mb:search:recording:" + query + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBRecording + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + c.logger.Info("musicbrainz search recordings", + "query", query, + "limit", limit, + ) + + result, err := c.mb.SearchRecordings(ctx, + musicbrainzws2.SearchFilter{Query: query}, + musicbrainzws2.Paginator{Limit: clampLimit(limit)}, + ) + if err != nil { + return nil, err + } + + out := convertRecordings(result.Recordings) + + c.cacheJSON(cacheKey, out, cacheTTLSearch, "", "") + + return out, nil +} + +// --------------------------------------------------------------------------- +// Lookup +// --------------------------------------------------------------------------- + +// LookupArtist fetches a single artist by MBID. Cached for 7 days. +func (c *MusicBrainzClient) LookupArtist( + ctx context.Context, mbid string, +) (*MBArtist, error) { + cacheKey := "mb:lookup:artist:" + mbid + + if data, ok := c.cache.Get(cacheKey); ok { + var out MBArtist + if err := json.Unmarshal(data, &out); err == nil { + return &out, nil + } + } + + c.logger.Info("musicbrainz lookup artist", "mbid", mbid) + + a, err := c.mb.LookupArtist(ctx, + mbtypes.MBID(mbid), + musicbrainzws2.IncludesFilter{}, + ) + if err != nil { + return nil, err + } + + out := convertArtist(a) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "artist") + + return &out, nil +} + +// LookupReleaseGroup fetches a single release group by MBID. +func (c *MusicBrainzClient) LookupReleaseGroup( + ctx context.Context, mbid string, +) (*MBReleaseGroup, error) { + cacheKey := "mb:lookup:release-group:" + mbid + + if data, ok := c.cache.Get(cacheKey); ok { + var out MBReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return &out, nil + } + } + + c.logger.Info("musicbrainz lookup release group", "mbid", mbid) + + rg, err := c.mb.LookupReleaseGroup(ctx, + mbtypes.MBID(mbid), + musicbrainzws2.IncludesFilter{Includes: []string{"artist-credits"}}, + ) + if err != nil { + return nil, err + } + + out := convertReleaseGroup(rg) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release-group") + + return &out, nil +} + +// --------------------------------------------------------------------------- +// Browse +// --------------------------------------------------------------------------- + +// BrowseReleaseGroups fetches the release groups for a given artist +// MBID. Cached for 7 days. +func (c *MusicBrainzClient) BrowseReleaseGroups( + ctx context.Context, artistMBID string, +) ([]MBReleaseGroup, error) { + cacheKey := "mb:browse:release-groups:" + artistMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBReleaseGroup + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + c.logger.Info("musicbrainz browse release groups", + "artistMBID", artistMBID, + ) + + result, err := c.mb.BrowseReleaseGroups(ctx, + musicbrainzws2.ReleaseGroupFilter{ + ArtistMBID: mbtypes.MBID(artistMBID), + }, + musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit}, + ) + if err != nil { + return nil, err + } + + out := convertReleaseGroups(result.ReleaseGroups) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist") + + return out, nil +} + +// BrowseReleases fetches the releases for a given release group +// MBID, including media/track information. Cached for 7 days. +func (c *MusicBrainzClient) BrowseReleases( + ctx context.Context, releaseGroupMBID string, +) ([]MBRelease, error) { + cacheKey := "mb:browse:releases:" + releaseGroupMBID + + if data, ok := c.cache.Get(cacheKey); ok { + var out []MBRelease + if err := json.Unmarshal(data, &out); err == nil { + return out, nil + } + } + + c.logger.Info("musicbrainz browse releases", + "releaseGroupMBID", releaseGroupMBID, + ) + + result, err := c.mb.BrowseReleases(ctx, + musicbrainzws2.ReleaseFilter{ + ReleaseGroupMBID: mbtypes.MBID(releaseGroupMBID), + Includes: []string{"recordings", "media"}, + }, + musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit}, + ) + if err != nil { + return nil, err + } + + out := convertReleases(result.Releases) + + c.cacheJSON(cacheKey, out, cacheTTLEntity, releaseGroupMBID, "release-group") + + return out, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// cacheJSON marshals v to JSON and stores it in the cache. +func (c *MusicBrainzClient) cacheJSON( + key string, + v any, + ttl time.Duration, + mbid string, + entityType string, +) { + data, err := json.Marshal(v) + if err != nil { + c.logger.Warn("musicbrainz cache marshal error", + "key", key, + "err", err, + ) + + return + } + + c.cache.Set(key, data, ttl, mbid, entityType) +} + +// clampLimit restricts the search limit to the MusicBrainz maximum. +func clampLimit(limit int) int { + if limit <= 0 || limit > musicbrainzws2.MaxLimit { + return musicbrainzws2.DefaultLimit + } + + return limit +} + +// --------------------------------------------------------------------------- +// Type converters (musicbrainzws2 → Wails wrapper types) +// --------------------------------------------------------------------------- + +func convertArtist(a musicbrainzws2.Artist) MBArtist { + return MBArtist{ + MBID: string(a.ID), + Name: a.Name, + SortName: a.SortName, + Type: a.Type, + Country: string(a.CountryCode), + Disambiguation: a.Disambiguation, + Score: a.Score, + } +} + +func convertArtists(artists []musicbrainzws2.Artist) []MBArtist { + out := make([]MBArtist, len(artists)) + for i, a := range artists { + out[i] = convertArtist(a) + } + + return out +} + +func convertReleaseGroup(rg musicbrainzws2.ReleaseGroup) MBReleaseGroup { + return MBReleaseGroup{ + MBID: string(rg.ID), + Title: rg.Title, + PrimaryType: rg.PrimaryType, + SecondaryTypes: rg.SecondaryTypes, + FirstReleaseDate: rg.FirstReleaseDate.String(), + ArtistCredit: rg.ArtistCredit.String(), + } +} + +func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup { + out := make([]MBReleaseGroup, len(rgs)) + for i, rg := range rgs { + out[i] = convertReleaseGroup(rg) + } + + return out +} + +func convertRelease(r musicbrainzws2.Release) MBRelease { + rel := MBRelease{ + MBID: string(r.ID), + Title: r.Title, + Date: r.Date.String(), + Country: string(r.CountryCode), + Status: r.Status, + } + + for _, m := range r.Media { + for _, t := range m.Tracks { + rel.Tracks = append(rel.Tracks, MBTrack{ + Position: t.Position, + Title: t.Title, + Length: int(t.Length.Milliseconds()), + MBID: string(t.ID), + }) + } + } + + return rel +} + +func convertReleases(releases []musicbrainzws2.Release) []MBRelease { + out := make([]MBRelease, len(releases)) + for i, r := range releases { + out[i] = convertRelease(r) + } + + return out +} + +func convertRecording(r musicbrainzws2.Recording) MBRecording { + return MBRecording{ + MBID: string(r.ID), + Title: r.Title, + Length: int(r.Length.Milliseconds()), + ArtistCredit: r.ArtistCredit.String(), + Score: r.Score, + } +} + +func convertRecordings(recordings []musicbrainzws2.Recording) []MBRecording { + out := make([]MBRecording, len(recordings)) + for i, r := range recordings { + out[i] = convertRecording(r) + } + + return out +} diff --git a/backend/explore/types.go b/backend/explore/types.go new file mode 100644 index 0000000..5afda9f --- /dev/null +++ b/backend/explore/types.go @@ -0,0 +1,83 @@ +package explore + +// Wails-serializable wrapper types for MusicBrainz, ListenBrainz, +// and Cover Art Archive API responses. These are the types that +// appear in the generated TypeScript bindings — all fields are +// exported with plain Go types (no mbtypes.MBID, no +// mbtypes.Duration) so the Wails type generator produces clean TS +// interfaces. + +// MBSearchResult aggregates the three searchable entity types +// returned by the MusicBrainz search API. +type MBSearchResult struct { + Artists []MBArtist `json:"artists,omitempty"` + ReleaseGroups []MBReleaseGroup `json:"releaseGroups,omitempty"` + Recordings []MBRecording `json:"recordings,omitempty"` +} + +// MBArtist is a Wails-friendly projection of a MusicBrainz artist. +type MBArtist struct { + MBID string `json:"mbid"` + Name string `json:"name"` + SortName string `json:"sortName"` + Type string `json:"type"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + Score int `json:"score"` +} + +// MBReleaseGroup is a Wails-friendly projection of a MusicBrainz +// release group. +type MBReleaseGroup struct { + MBID string `json:"mbid"` + Title string `json:"title"` + PrimaryType string `json:"primaryType"` + SecondaryTypes []string `json:"secondaryTypes,omitempty"` + FirstReleaseDate string `json:"firstReleaseDate"` + ArtistCredit string `json:"artistCredit"` +} + +// MBRelease is a Wails-friendly projection of a MusicBrainz release. +type MBRelease struct { + MBID string `json:"mbid"` + Title string `json:"title"` + Date string `json:"date"` + Country string `json:"country"` + Status string `json:"status"` + Tracks []MBTrack `json:"tracks,omitempty"` +} + +// MBRecording is a Wails-friendly projection of a MusicBrainz +// recording. +type MBRecording struct { + MBID string `json:"mbid"` + Title string `json:"title"` + Length int `json:"length"` + ArtistCredit string `json:"artistCredit"` + Score int `json:"score"` +} + +// MBTrack is a Wails-friendly projection of a MusicBrainz track. +type MBTrack struct { + Position int `json:"position"` + Title string `json:"title"` + Length int `json:"length"` + MBID string `json:"mbid"` +} + +// LBTopRecording represents a popular recording from the +// ListenBrainz popularity API. +type LBTopRecording struct { + RecordingMBID string `json:"recordingMbid"` + ArtistName string `json:"artistName"` + TrackName string `json:"trackName"` + TotalListenCount int `json:"totalListenCount"` +} + +// LBSimilarArtist represents a similar artist from the +// ListenBrainz labs API. +type LBSimilarArtist struct { + ArtistMBID string `json:"artistMbid"` + Name string `json:"name"` + Score float64 `json:"score"` +} diff --git a/go.mod b/go.mod index 9398771..781031b 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,12 @@ require ( github.com/golang-cz/devslog v0.0.15 github.com/gopxl/beep/v2 v2.1.1 github.com/wailsapp/wails/v2 v2.10.2 + go.uploadedlobster.com/mbtypes v0.4.0 + go.uploadedlobster.com/musicbrainzws2 v0.18.0 golang.org/x/image v0.12.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.34.0 + golang.org/x/time v0.15.0 modernc.org/sqlite v1.46.1 ) @@ -121,6 +124,7 @@ require ( github.com/go-git/go-git/v5 v5.13.2 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect @@ -354,7 +358,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect golang.org/x/term v0.40.0 // indirect - golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.org/x/vuln v1.1.4 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect diff --git a/go.sum b/go.sum index 74a7143..fa92231 100644 --- a/go.sum +++ b/go.sum @@ -324,6 +324,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -1007,6 +1009,10 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s= +go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM= +go.uploadedlobster.com/musicbrainzws2 v0.18.0 h1:fNhAadkhq6L9x+p02xhU6yrQ6AJq892gt+LMkvwf9/w= +go.uploadedlobster.com/musicbrainzws2 v0.18.0/go.mod h1:CUXMHdvAnAV58VOoLtoXGD8MUrI5FkDMKqmrHL84MGo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=