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:
@@ -39,10 +39,18 @@ func (r *noopAlbumRepo) GetByArtistID(ctx context.Context, artistID string, limi
|
||||
return &domain.SearchResult[domain.Album]{}, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) Save(ctx context.Context, album *domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopTrackRepo struct{}
|
||||
|
||||
func (r *noopTrackRepo) GetByID(ctx context.Context, id string) (*domain.Track, error) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP EXTENSION IF EXISTS pg_prewarm;
|
||||
@@ -1 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
|
||||
@@ -1,33 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_playlist_tracks_position;
|
||||
DROP INDEX IF EXISTS idx_lyrics_track_id;
|
||||
DROP INDEX IF EXISTS idx_genres_name;
|
||||
DROP INDEX IF EXISTS idx_albums_release_date;
|
||||
DROP INDEX IF EXISTS idx_albums_source;
|
||||
DROP INDEX IF EXISTS idx_albums_upc;
|
||||
DROP INDEX IF EXISTS idx_tracks_source;
|
||||
DROP INDEX IF EXISTS idx_tracks_isrc;
|
||||
DROP INDEX IF EXISTS idx_artists_source;
|
||||
DROP INDEX IF EXISTS idx_artists_name;
|
||||
|
||||
DROP TABLE IF EXISTS track_external_ids;
|
||||
DROP TABLE IF EXISTS album_external_ids;
|
||||
DROP TABLE IF EXISTS artist_external_ids;
|
||||
|
||||
DROP TABLE IF EXISTS playlist_tracks;
|
||||
DROP TABLE IF EXISTS playlists;
|
||||
DROP TABLE IF EXISTS lyrics;
|
||||
|
||||
DROP TABLE IF EXISTS similar_artists;
|
||||
DROP TABLE IF EXISTS album_genres;
|
||||
DROP TABLE IF EXISTS artist_genres;
|
||||
DROP TABLE IF EXISTS work_artists;
|
||||
DROP TABLE IF EXISTS album_tracks;
|
||||
DROP TABLE IF EXISTS album_artists;
|
||||
DROP TABLE IF EXISTS track_artists;
|
||||
|
||||
DROP TABLE IF EXISTS genres;
|
||||
DROP TABLE IF EXISTS albums;
|
||||
DROP TABLE IF EXISTS labels;
|
||||
DROP TABLE IF EXISTS tracks;
|
||||
DROP TABLE IF EXISTS works;
|
||||
DROP TABLE IF EXISTS artists;
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Core Entities
|
||||
CREATE EXTENSION IF NOT EXISTS pg_prewarm;
|
||||
|
||||
CREATE TABLE artists (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -56,6 +56,7 @@ CREATE TABLE albums (
|
||||
label_id UUID REFERENCES labels(id),
|
||||
title TEXT NOT NULL,
|
||||
album_type TEXT,
|
||||
secondary_types TEXT[] DEFAULT '{}',
|
||||
release_date DATE,
|
||||
upc TEXT,
|
||||
total_tracks INT,
|
||||
@@ -73,8 +74,6 @@ CREATE TABLE genres (
|
||||
parent_id UUID REFERENCES genres(id)
|
||||
);
|
||||
|
||||
-- Relationships
|
||||
|
||||
CREATE TABLE track_artists (
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||
@@ -125,8 +124,6 @@ CREATE TABLE similar_artists (
|
||||
PRIMARY KEY (artist_id, similar_artist_id)
|
||||
);
|
||||
|
||||
-- Content
|
||||
|
||||
CREATE TABLE lyrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
@@ -156,8 +153,6 @@ CREATE TABLE playlist_tracks (
|
||||
PRIMARY KEY (playlist_id, track_id)
|
||||
);
|
||||
|
||||
-- External IDs
|
||||
|
||||
CREATE TABLE artist_external_ids (
|
||||
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL,
|
||||
@@ -185,8 +180,6 @@ CREATE TABLE track_external_ids (
|
||||
PRIMARY KEY (track_id, source, source_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
||||
CREATE INDEX idx_artists_name ON artists(name);
|
||||
CREATE INDEX idx_artists_source ON artists(source, source_id);
|
||||
CREATE INDEX idx_tracks_isrc ON tracks(isrc) WHERE isrc IS NOT NULL;
|
||||
+13
-12
@@ -17,18 +17,19 @@ type Artist struct {
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string
|
||||
Title string
|
||||
Type string
|
||||
ReleaseDate *time.Time
|
||||
UPC string
|
||||
TotalTracks int
|
||||
TotalDiscs int
|
||||
CoverURL string
|
||||
Artists []ArtistCredit
|
||||
Label *Label
|
||||
Genres []Genre
|
||||
ExternalIDs []ExternalID
|
||||
ID string
|
||||
Title string
|
||||
Type string
|
||||
SecondaryTypes []string
|
||||
ReleaseDate *time.Time
|
||||
UPC string
|
||||
TotalTracks int
|
||||
TotalDiscs int
|
||||
CoverURL string
|
||||
Artists []ArtistCredit
|
||||
Label *Label
|
||||
Genres []Genre
|
||||
ExternalIDs []ExternalID
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,8 +22,8 @@ func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
||||
|
||||
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*domain.Album, error) {
|
||||
query := `
|
||||
SELECT id, title, album_type, release_date, upc, total_tracks, total_discs,
|
||||
cover_url, source, source_id
|
||||
SELECT id, title, album_type, secondary_types, release_date, upc, total_tracks,
|
||||
total_discs, cover_url, source, source_id
|
||||
FROM albums
|
||||
WHERE id = $1`
|
||||
|
||||
@@ -41,8 +41,8 @@ func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*domain.Album
|
||||
|
||||
func (r *AlbumRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error) {
|
||||
query := `
|
||||
SELECT a.id, a.title, a.album_type, a.release_date, a.upc, a.total_tracks,
|
||||
a.total_discs, a.cover_url, a.source, a.source_id
|
||||
SELECT a.id, a.title, a.album_type, a.secondary_types, a.release_date, a.upc,
|
||||
a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_external_ids e ON a.id = e.album_id
|
||||
WHERE e.source = $1 AND e.source_id = $2`
|
||||
@@ -68,8 +68,8 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, li
|
||||
WHERE ae.source_id = $1`
|
||||
|
||||
searchQuery := `
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.release_date, a.upc,
|
||||
a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.secondary_types, a.release_date,
|
||||
a.upc, a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_artists aa ON a.id = aa.album_id
|
||||
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
||||
@@ -105,6 +105,106 @@ func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, li
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
query := `
|
||||
SELECT DISTINCT a.id, a.title, a.album_type, a.secondary_types, a.release_date,
|
||||
a.upc, a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
||||
FROM albums a
|
||||
JOIN album_artists aa ON a.id = aa.album_id
|
||||
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
||||
WHERE ae.source_id = $1
|
||||
ORDER BY a.release_date DESC NULLS LAST`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var albums []domain.Album
|
||||
for rows.Next() {
|
||||
album, err := r.scanAlbumFromRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.loadRelations(ctx, album); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albums = append(albums, *album)
|
||||
}
|
||||
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for i := range albums {
|
||||
album := &albums[i]
|
||||
|
||||
var source, sourceID string
|
||||
if len(album.ExternalIDs) > 0 {
|
||||
source = album.ExternalIDs[0].Source
|
||||
sourceID = album.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO albums (id, title, album_type, secondary_types, release_date, upc,
|
||||
total_tracks, total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
secondary_types = EXCLUDED.secondary_types,
|
||||
release_date = EXCLUDED.release_date,
|
||||
upc = EXCLUDED.upc,
|
||||
total_tracks = EXCLUDED.total_tracks,
|
||||
total_discs = EXCLUDED.total_discs,
|
||||
cover_url = EXCLUDED.cover_url,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
album.ID, album.Title, nullString(album.Type), album.SecondaryTypes,
|
||||
album.ReleaseDate, nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
nullString(album.CoverURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range album.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO album_external_ids (album_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, album.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, ac := range album.Artists {
|
||||
artistQuery := `
|
||||
INSERT INTO album_artists (album_id, artist_id, role, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, artist_id, role) DO NOTHING`
|
||||
|
||||
_, err = tx.Exec(ctx, artistQuery, album.ID, ac.Artist.ID, ac.Role, ac.Position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -119,12 +219,13 @@ func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO albums (id, title, album_type, release_date, upc, total_tracks,
|
||||
total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO albums (id, title, album_type, secondary_types, release_date, upc,
|
||||
total_tracks, total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
secondary_types = EXCLUDED.secondary_types,
|
||||
release_date = EXCLUDED.release_date,
|
||||
upc = EXCLUDED.upc,
|
||||
total_tracks = EXCLUDED.total_tracks,
|
||||
@@ -133,8 +234,8 @@ func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
album.ID, album.Title, nullString(album.Type), album.ReleaseDate,
|
||||
nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
album.ID, album.Title, nullString(album.Type), album.SecondaryTypes,
|
||||
album.ReleaseDate, nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
||||
nullString(album.CoverURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +293,7 @@ func (r *AlbumRepository) scanAlbumRow(row pgx.Row) (*domain.Album, error) {
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&album.ID, &album.Title, &albumType, &releaseDate, &upc,
|
||||
&album.ID, &album.Title, &albumType, &album.SecondaryTypes, &releaseDate, &upc,
|
||||
&totalTracks, &totalDiscs, &coverURL, &source, &sourceID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -17,7 +17,9 @@ type AlbumRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.Album, error)
|
||||
GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error)
|
||||
GetByArtistID(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error)
|
||||
Save(ctx context.Context, album *domain.Album) error
|
||||
SaveAll(ctx context.Context, albums []domain.Album) error
|
||||
}
|
||||
|
||||
type TrackRepository interface {
|
||||
|
||||
+35
-13
@@ -3,11 +3,13 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/provider/musicbrainz"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
"github.com/metadata-agregator/internal/service"
|
||||
@@ -114,10 +116,9 @@ func (s *MetadataServer) SearchAlbums(ctx context.Context, req *metadatav1.Searc
|
||||
limit = 25
|
||||
}
|
||||
|
||||
albumTypes := defaultAlbumTypes(req.AlbumTypes)
|
||||
log.Debug().Str("query", req.Query).Str("artist", req.Artist).Int("limit", limit).Int("offset", int(req.Offset)).Strs("album_types", albumTypes).Msg("searching albums")
|
||||
log.Debug().Str("query", req.Query).Str("artist", req.Artist).Int("limit", limit).Int("offset", int(req.Offset)).Msg("searching albums")
|
||||
|
||||
result, err := svc.SearchAlbums(ctx, req.Query, req.Artist, limit, int(req.Offset), albumTypes)
|
||||
result, err := svc.SearchAlbums(ctx, req.Query, req.Artist, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
@@ -172,28 +173,25 @@ func (s *MetadataServer) GetArtistAlbums(ctx context.Context, req *metadatav1.Ge
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
albumTypes := defaultAlbumTypes(req.AlbumTypes)
|
||||
log.Debug().Str("artist_id", req.ArtistId).Int("limit", limit).Int("offset", int(req.Offset)).Strs("album_types", albumTypes).Msg("getting artist albums")
|
||||
log.Debug().Str("artist_id", req.ArtistId).Strs("album_types", albumTypes).Msg("getting artist albums")
|
||||
|
||||
result, err := svc.GetArtistAlbums(ctx, req.ArtistId, limit, int(req.Offset), albumTypes)
|
||||
allAlbums, err := svc.GetArtistAlbums(ctx, req.ArtistId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(ctx, err)
|
||||
}
|
||||
|
||||
filtered := filterAlbums(allAlbums, albumTypes)
|
||||
|
||||
resp := &metadatav1.GetArtistAlbumsResponse{
|
||||
Total: int32(result.Total),
|
||||
Total: int32(len(filtered)),
|
||||
}
|
||||
|
||||
for _, a := range result.Items {
|
||||
for _, a := range filtered {
|
||||
resp.Albums = append(resp.Albums, toProtoAlbum(&a))
|
||||
}
|
||||
|
||||
log.Trace().Int("total", result.Total).Int("returned", len(resp.Albums)).Msg("artist albums retrieved")
|
||||
log.Trace().Int("total_fetched", len(allAlbums)).Int("filtered", len(filtered)).Strs("album_types", albumTypes).Msg("artist albums retrieved")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -279,6 +277,25 @@ func defaultAlbumTypes(types []string) []string {
|
||||
return defaultTypes
|
||||
}
|
||||
|
||||
func filterAlbums(albums []domain.Album, types []string) []domain.Album {
|
||||
allowed := make(map[string]bool, len(types))
|
||||
for _, t := range types {
|
||||
allowed[strings.ToLower(t)] = true
|
||||
}
|
||||
|
||||
var result []domain.Album
|
||||
for _, a := range albums {
|
||||
if len(a.SecondaryTypes) > 0 {
|
||||
continue
|
||||
}
|
||||
if !allowed[strings.ToLower(a.Type)] {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toGRPCError(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -286,6 +303,11 @@ func toGRPCError(ctx context.Context, err error) error {
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrBadRequest) {
|
||||
log.Debug().Err(err).Msg("bad request")
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
log.Debug().Msg("entity not found")
|
||||
return status.Error(codes.NotFound, "not found")
|
||||
|
||||
@@ -80,9 +80,9 @@ func (s *MetadataService) SearchArtists(ctx context.Context, query string, limit
|
||||
return s.provider.SearchArtists(ctx, query, limit, offset)
|
||||
}
|
||||
|
||||
func (s *MetadataService) SearchAlbums(ctx context.Context, query string, artist string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error) {
|
||||
zerolog.Ctx(ctx).Debug().Str("query", query).Str("artist", artist).Strs("album_types", albumTypes).Str("provider", s.provider.Name()).Msg("searching albums via provider")
|
||||
return s.provider.SearchAlbums(ctx, query, artist, limit, offset, albumTypes)
|
||||
func (s *MetadataService) SearchAlbums(ctx context.Context, query string, artist string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
zerolog.Ctx(ctx).Debug().Str("query", query).Str("artist", artist).Str("provider", s.provider.Name()).Msg("searching albums via provider")
|
||||
return s.provider.SearchAlbums(ctx, query, artist, limit, offset)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAlbum(ctx context.Context, id string) (*domain.Album, error) {
|
||||
@@ -117,19 +117,57 @@ func (s *MetadataService) GetAlbum(ctx context.Context, id string) (*domain.Albu
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int, albumTypes []string) (*domain.SearchResult[domain.Album], error) {
|
||||
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
result, err := s.albums.GetByArtistID(ctx, artistID, limit, offset)
|
||||
if err == nil && len(result.Items) > 0 {
|
||||
log.Debug().Str("artist_id", artistID).Int("count", len(result.Items)).Msg("artist albums served from cache")
|
||||
cached, err := s.albums.GetAllByArtistID(ctx, artistID)
|
||||
if err == nil && len(cached) > 0 {
|
||||
log.Debug().Str("artist_id", artistID).Int("count", len(cached)).Msg("artist albums cache hit")
|
||||
metrics.CacheHits.WithLabelValues("artist_albums").Inc()
|
||||
return result, nil
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
metrics.CacheMisses.WithLabelValues("artist_albums").Inc()
|
||||
log.Debug().Str("artist_id", artistID).Strs("album_types", albumTypes).Str("provider", s.provider.Name()).Msg("artist albums cache miss, querying provider")
|
||||
return s.provider.GetArtistAlbums(ctx, artistID, limit, offset, albumTypes)
|
||||
log.Debug().Str("artist_id", artistID).Str("provider", s.provider.Name()).Msg("artist albums cache miss, fetching from provider")
|
||||
|
||||
albums, err := s.provider.GetArtistAlbums(ctx, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.ensureArtistsCached(ctx, albums)
|
||||
|
||||
if saveErr := s.albums.SaveAll(ctx, albums); saveErr != nil {
|
||||
log.Warn().Err(saveErr).Str("artist_id", artistID).Msg("failed to cache artist albums")
|
||||
} else {
|
||||
log.Debug().Str("artist_id", artistID).Int("count", len(albums)).Msg("artist albums cached")
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) ensureArtistsCached(ctx context.Context, albums []domain.Album) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, album := range albums {
|
||||
for _, ac := range album.Artists {
|
||||
id := ac.Artist.ID
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
_, err := s.artists.GetByID(ctx, id)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.GetArtist(ctx, id); err != nil {
|
||||
log.Warn().Err(err).Str("artist_id", id).Msg("failed to fetch and cache artist from album credit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
|
||||
@@ -39,10 +39,18 @@ func (r *noopAlbumRepo) GetByArtistID(ctx context.Context, artistID string, limi
|
||||
return &domain.SearchResult[domain.Album]{}, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) GetAllByArtistID(ctx context.Context, artistID string) ([]domain.Album, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) Save(ctx context.Context, album *domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *noopAlbumRepo) SaveAll(ctx context.Context, albums []domain.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noopTrackRepo struct{}
|
||||
|
||||
func (r *noopTrackRepo) GetByID(ctx context.Context, id string) (*domain.Track, error) {
|
||||
|
||||
Reference in New Issue
Block a user