perf(S01/T02): Add MusicBrainz search/lookup/browse client, ListenBrain…
- backend/explore/types.go - backend/explore/musicbrainz.go - backend/explore/listenbrainz.go - backend/explore/coverart.go - backend/explore/coverart_test.go
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -14,9 +14,12 @@ require (
|
|||||||
github.com/golang-cz/devslog v0.0.15
|
github.com/golang-cz/devslog v0.0.15
|
||||||
github.com/gopxl/beep/v2 v2.1.1
|
github.com/gopxl/beep/v2 v2.1.1
|
||||||
github.com/wailsapp/wails/v2 v2.10.2
|
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/image v0.12.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
modernc.org/sqlite v1.46.1
|
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-git/go-git/v5 v5.13.2 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // 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-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-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||||
github.com/go-toolsmith/astcopy 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/sys v0.41.0 // indirect
|
||||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||||
golang.org/x/term v0.40.0 // 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/tools v0.42.0 // indirect
|
||||||
golang.org/x/vuln v1.1.4 // indirect
|
golang.org/x/vuln v1.1.4 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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=
|
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.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 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
|||||||
Reference in New Issue
Block a user