feat: fetch all artist albums with caching, filter by type in handler
- Provider fetches all release groups via paginated browse, no type filter - Service caches all albums in DB, ensures referenced artists exist first - Server handler filters by album_types (default: album|ep|single), excludes secondary types - Add secondary_types to domain, DB schema, and MB types - Add ErrBadRequest handling for MusicBrainz 400 errors - Replace migrations with single schema.sql - Add GetAllByArtistID and SaveAll to album repository
This commit is contained in:
@@ -107,6 +107,12 @@ func (c *client) readResponse(ctx context.Context, resp *http.Response, endpoint
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "ok").Inc()
|
||||
return io.ReadAll(resp.Body)
|
||||
|
||||
case http.StatusBadRequest:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Warn().Str("endpoint", endpoint).Str("body", string(body)).Dur("duration", duration).Msg("HTTP 400 bad request")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "bad_request").Inc()
|
||||
return nil, fmt.Errorf("%w: %s", ErrBadRequest, extractErrorMessage(body))
|
||||
|
||||
case http.StatusNotFound:
|
||||
log.Debug().Str("endpoint", endpoint).Dur("duration", duration).Msg("HTTP 404")
|
||||
metrics.ProviderRequests.WithLabelValues("musicbrainz", endpoint, "not_found").Inc()
|
||||
@@ -137,17 +143,12 @@ func (c *client) lookup(ctx context.Context, entity, id string, inc []string) ([
|
||||
}
|
||||
|
||||
func (c *client) browse(ctx context.Context, entity, linkedEntity, linkedID string, limit, offset int, inc []string) ([]byte, error) {
|
||||
return c.browseWithTypes(ctx, entity, linkedEntity, linkedID, limit, offset, inc, nil)
|
||||
}
|
||||
|
||||
func (c *client) browseWithTypes(ctx context.Context, entity, linkedEntity, linkedID string, limit, offset int, inc []string, types []string) ([]byte, error) {
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Str("entity", entity).
|
||||
Str("linked_entity", linkedEntity).
|
||||
Str("linked_id", linkedID).
|
||||
Int("limit", limit).
|
||||
Int("offset", offset).
|
||||
Strs("types", types).
|
||||
Msg("provider browse")
|
||||
|
||||
params := url.Values{}
|
||||
@@ -159,10 +160,6 @@ func (c *client) browseWithTypes(ctx context.Context, entity, linkedEntity, link
|
||||
params.Set("inc", joined)
|
||||
}
|
||||
|
||||
if typeStr := strings.Join(types, "|"); typeStr != "" {
|
||||
params.Set("type", typeStr)
|
||||
}
|
||||
|
||||
return c.get(ctx, entity, params)
|
||||
}
|
||||
|
||||
@@ -184,6 +181,16 @@ func joinIncludes(inc []string) string {
|
||||
return strings.Join(inc, "+")
|
||||
}
|
||||
|
||||
func extractErrorMessage(body []byte) string {
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
|
||||
return errResp.Error
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func decode[T any](data []byte) (*T, error) {
|
||||
var result T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
|
||||
@@ -4,5 +4,6 @@ import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
)
|
||||
|
||||
@@ -61,9 +61,10 @@ func mapAlbum(mb *mbReleaseGroup, release *mbRelease) *domain.Album {
|
||||
}
|
||||
|
||||
album := &domain.Album{
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.PrimaryType,
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.PrimaryType,
|
||||
SecondaryTypes: mb.SecondaryTypes,
|
||||
ExternalIDs: []domain.ExternalID{{
|
||||
Source: "musicbrainz",
|
||||
SourceID: mb.ID,
|
||||
|
||||
@@ -91,7 +91,7 @@ func (p *Provider) GetAlbum(ctx context.Context, id string) (*domain.Album, erro
|
||||
return mapAlbum(mb, release), nil
|
||||
}
|
||||
|
||||
func (p *Provider) SearchAlbums(ctx context.Context, query string, artist string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error) {
|
||||
func (p *Provider) SearchAlbums(ctx context.Context, query string, artist string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
@@ -105,10 +105,6 @@ func (p *Provider) SearchAlbums(ctx context.Context, query string, artist string
|
||||
luceneQuery = fmt.Sprintf("releasegroup:%s", escapeQuery(query))
|
||||
}
|
||||
|
||||
if typeFilter := buildTypeFilter(albumTypes); typeFilter != "" {
|
||||
luceneQuery += " AND " + typeFilter
|
||||
}
|
||||
|
||||
data, err := p.client.search(ctx, "release-group", luceneQuery, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search albums: %w", err)
|
||||
@@ -138,38 +134,38 @@ func (p *Provider) SearchAlbums(ctx context.Context, query string, artist string
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
func (p *Provider) GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
var allAlbums []domain.Album
|
||||
offset := 0
|
||||
batchSize := 100
|
||||
|
||||
data, err := p.client.browseWithTypes(ctx, "release-group", "artist", artistID, limit, offset, []string{"artist-credits"}, albumTypes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse release-groups: %w", err)
|
||||
}
|
||||
for {
|
||||
data, err := p.client.browse(ctx, "release-group", "artist", artistID, batchSize, offset, []string{"artist-credits"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse release-groups: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ReleaseGroupCount int `json:"release-group-count"`
|
||||
ReleaseGroupOffset int `json:"release-group-offset"`
|
||||
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
if err := decodeInto(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ReleaseGroupCount int `json:"release-group-count"`
|
||||
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
if err := decodeInto(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &domain.SearchResult[domain.Album]{
|
||||
Total: resp.ReleaseGroupCount,
|
||||
Limit: limit,
|
||||
Offset: resp.ReleaseGroupOffset,
|
||||
}
|
||||
for _, mb := range resp.ReleaseGroups {
|
||||
if album := mapAlbum(mb, nil); album != nil {
|
||||
allAlbums = append(allAlbums, *album)
|
||||
}
|
||||
}
|
||||
|
||||
for _, mb := range resp.ReleaseGroups {
|
||||
if album := mapAlbum(mb, nil); album != nil {
|
||||
result.Items = append(result.Items, *album)
|
||||
offset += len(resp.ReleaseGroups)
|
||||
if offset >= resp.ReleaseGroupCount || len(resp.ReleaseGroups) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return allAlbums, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
@@ -315,20 +311,6 @@ func selectCanonicalRelease(releases []*mbRelease) *mbRelease {
|
||||
return best
|
||||
}
|
||||
|
||||
func buildTypeFilter(types []string) string {
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(types) == 1 {
|
||||
return fmt.Sprintf("primarytype:%s", types[0])
|
||||
}
|
||||
escaped := make([]string, len(types))
|
||||
for i, t := range types {
|
||||
escaped[i] = fmt.Sprintf("primarytype:%s", t)
|
||||
}
|
||||
return "(" + strings.Join(escaped, " OR ") + ")"
|
||||
}
|
||||
|
||||
func escapeQuery(s string) string {
|
||||
special := []string{`+`, `-`, `&`, `|`, `!`, `(`, `)`, `{`, `}`, `[`, `]`, `^`, `"`, `~`, `*`, `?`, `:`, `/`, `\`}
|
||||
result := s
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package musicbrainz
|
||||
|
||||
type mbArtist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortName string `json:"sort-name"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
LifeSpan mbLifeSpan `json:"life-span"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortName string `json:"sort-name"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
LifeSpan mbLifeSpan `json:"life-span"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
}
|
||||
|
||||
type mbLifeSpan struct {
|
||||
@@ -22,6 +22,7 @@ type mbReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
SecondaryTypes []string `json:"secondary-types"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
@@ -29,16 +30,16 @@ type mbReleaseGroup struct {
|
||||
}
|
||||
|
||||
type mbRelease struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Barcode string `json:"barcode"`
|
||||
LabelInfo []mbLabelInfo `json:"label-info"`
|
||||
Media []mbMedium `json:"media"`
|
||||
ReleaseGroup *mbReleaseGroup `json:"release-group"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Barcode string `json:"barcode"`
|
||||
LabelInfo []mbLabelInfo `json:"label-info"`
|
||||
Media []mbMedium `json:"media"`
|
||||
ReleaseGroup *mbReleaseGroup `json:"release-group"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
CoverArtArchive mbCoverArtArchive `json:"cover-art-archive"`
|
||||
}
|
||||
|
||||
@@ -60,10 +61,10 @@ type mbLabel struct {
|
||||
}
|
||||
|
||||
type mbMedium struct {
|
||||
Position int `json:"position"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"track-count"`
|
||||
Tracks []mbTrack `json:"tracks"`
|
||||
Position int `json:"position"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"track-count"`
|
||||
Tracks []mbTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type mbTrack struct {
|
||||
|
||||
@@ -13,8 +13,8 @@ type Provider interface {
|
||||
SearchArtists(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error)
|
||||
|
||||
GetAlbum(ctx context.Context, id string) (*domain.Album, error)
|
||||
SearchAlbums(ctx context.Context, query string, artist string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error)
|
||||
GetArtistAlbums(ctx context.Context, artistID string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error)
|
||||
SearchAlbums(ctx context.Context, query string, artist string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error)
|
||||
|
||||
GetTrack(ctx context.Context, id string) (*domain.Track, error)
|
||||
GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error)
|
||||
|
||||
Reference in New Issue
Block a user