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:
Alexander
2026-05-07 20:03:04 +02:00
parent 35ac167952
commit 9f1318e79e
17 changed files with 302 additions and 172 deletions
+16 -9
View File
@@ -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 {
+1
View File
@@ -4,5 +4,6 @@ import "errors"
var (
ErrNotFound = errors.New("not found")
ErrBadRequest = errors.New("bad request")
ErrRateLimited = errors.New("rate limited")
)
+4 -3
View File
@@ -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,
+26 -44
View File
@@ -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
+24 -23
View File
@@ -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 {
+2 -2
View File
@@ -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)