Files
metadata-agregator/internal/provider/musicbrainz/provider.go
T
Alexander 2756cc1043 feat: fuzzy search with parallel DB/MusicBrainz execution
- Add pg_trgm fuzzy search to PostgreSQL artist repository
- Add Lucene fuzzy query (~0.7) for MusicBrainz artist search
- Run DB and MusicBrainz searches in parallel goroutines
- Cache MusicBrainz results to DB for accumulation
- Deduplicate results by external ID
- Filter out special purpose artists (type=Other)
- Add SaveAll method for batch artist inserts
- Move database schema to containers repo
2026-05-10 21:13:17 +02:00

351 lines
8.0 KiB
Go

package musicbrainz
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/metadata-agregator/internal/domain"
)
type Provider struct {
client *client
}
func New() *Provider {
return &Provider{
client: newClient(),
}
}
func (p *Provider) Name() string {
return "musicbrainz"
}
func (p *Provider) GetArtist(ctx context.Context, id string) (*domain.Artist, error) {
data, err := p.client.lookup(ctx, "artist", id, []string{"genres", "url-rels"})
if err != nil {
return nil, fmt.Errorf("lookup artist: %w", err)
}
mb, err := decode[mbArtist](data)
if err != nil {
return nil, err
}
return mapArtist(mb), nil
}
func (p *Provider) SearchArtists(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
if limit <= 0 || limit > 100 {
limit = 25
}
luceneQuery := buildFuzzyArtistQuery(query, 0.7)
data, err := p.client.search(ctx, "artist", luceneQuery, limit, offset)
if err != nil {
return nil, fmt.Errorf("search artists: %w", err)
}
var resp struct {
Count int `json:"count"`
Offset int `json:"offset"`
Artists []*mbArtist `json:"artists"`
}
if err := decodeInto(data, &resp); err != nil {
return nil, err
}
result := &domain.SearchResult[domain.Artist]{
Total: resp.Count,
Limit: limit,
Offset: offset,
}
for _, mb := range resp.Artists {
if artist := mapArtist(mb); artist != nil {
result.Items = append(result.Items, *artist)
}
}
return result, nil
}
func (p *Provider) GetAlbum(ctx context.Context, id string) (*domain.Album, error) {
data, err := p.client.lookup(ctx, "release-group", id, []string{"releases", "artist-credits", "genres"})
if err != nil {
return nil, fmt.Errorf("lookup release-group: %w", err)
}
mb, err := decode[mbReleaseGroup](data)
if err != nil {
return nil, err
}
var release *mbRelease
if len(mb.Releases) > 0 {
release = selectCanonicalRelease(mb.Releases)
}
return mapAlbum(mb, release), nil
}
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
}
var luceneQuery string
if artist != "" && query != "" {
luceneQuery = fmt.Sprintf("releasegroup:%s AND artist:%s", escapeQuery(query), escapeQuery(artist))
} else if artist != "" {
luceneQuery = fmt.Sprintf("artist:%s", escapeQuery(artist))
} else {
luceneQuery = fmt.Sprintf("releasegroup:%s", escapeQuery(query))
}
data, err := p.client.search(ctx, "release-group", luceneQuery, limit, offset)
if err != nil {
return nil, fmt.Errorf("search albums: %w", err)
}
var resp struct {
Count int `json:"count"`
Offset int `json:"offset"`
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
}
if err := decodeInto(data, &resp); err != nil {
return nil, err
}
result := &domain.SearchResult[domain.Album]{
Total: resp.Count,
Limit: limit,
Offset: offset,
}
for _, mb := range resp.ReleaseGroups {
if album := mapAlbum(mb, nil); album != nil {
result.Items = append(result.Items, *album)
}
}
return result, nil
}
func (p *Provider) GetArtistAlbums(ctx context.Context, artistID string) ([]domain.Album, error) {
var allAlbums []domain.Album
offset := 0
batchSize := 100
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"`
ReleaseGroups []*mbReleaseGroup `json:"release-groups"`
}
if err := decodeInto(data, &resp); err != nil {
return nil, err
}
for _, mb := range resp.ReleaseGroups {
if album := mapAlbum(mb, nil); album != nil {
allAlbums = append(allAlbums, *album)
}
}
offset += len(resp.ReleaseGroups)
if offset >= resp.ReleaseGroupCount || len(resp.ReleaseGroups) == 0 {
break
}
}
return allAlbums, nil
}
func (p *Provider) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
data, err := p.client.lookup(ctx, "recording", id, []string{"artist-credits", "isrcs", "work-rels"})
if err != nil {
return nil, fmt.Errorf("lookup recording: %w", err)
}
mb, err := decode[mbRecording](data)
if err != nil {
return nil, err
}
return mapTrack(mb, 0, 0), nil
}
func (p *Provider) GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error) {
data, err := p.client.browse(ctx, "release", "release-group", albumID, 100, 0, nil)
if err != nil {
return nil, fmt.Errorf("browse releases: %w", err)
}
var resp struct {
Releases []*mbRelease `json:"releases"`
}
if err := decodeInto(data, &resp); err != nil {
return nil, err
}
if len(resp.Releases) == 0 {
return nil, ErrNotFound
}
release := selectCanonicalRelease(resp.Releases)
releaseData, err := p.client.lookup(ctx, "release", release.ID, []string{"recordings", "artist-credits", "isrcs"})
if err != nil {
return nil, fmt.Errorf("lookup release: %w", err)
}
fullRelease, err := decode[mbRelease](releaseData)
if err != nil {
return nil, err
}
var tracks []domain.Track
for _, medium := range fullRelease.Media {
for _, t := range medium.Tracks {
if track := mapTrack(&t.Recording, medium.Position, t.Position); track != nil {
tracks = append(tracks, *track)
}
}
}
return tracks, nil
}
func (p *Provider) GetTrackByISRC(ctx context.Context, isrc string) (*domain.Track, error) {
data, err := p.client.get(ctx, fmt.Sprintf("isrc/%s", isrc), nil)
if err != nil {
return nil, fmt.Errorf("lookup isrc: %w", err)
}
var resp struct {
Recordings []*mbRecording `json:"recordings"`
}
if err := decodeInto(data, &resp); err != nil {
return nil, err
}
if len(resp.Recordings) == 0 {
return nil, ErrNotFound
}
return p.GetTrack(ctx, resp.Recordings[0].ID)
}
func (p *Provider) GetLabel(ctx context.Context, id string) (*domain.Label, error) {
data, err := p.client.lookup(ctx, "label", id, nil)
if err != nil {
return nil, fmt.Errorf("lookup label: %w", err)
}
mb, err := decode[mbLabel](data)
if err != nil {
return nil, err
}
return mapLabel(mb), nil
}
func (p *Provider) GetWork(ctx context.Context, id string) (*domain.Work, error) {
data, err := p.client.lookup(ctx, "work", id, []string{"artist-rels"})
if err != nil {
return nil, fmt.Errorf("lookup work: %w", err)
}
mb, err := decode[mbWork](data)
if err != nil {
return nil, err
}
return mapWork(mb), nil
}
func selectCanonicalRelease(releases []*mbRelease) *mbRelease {
if len(releases) == 0 {
return nil
}
var best *mbRelease
bestScore := -1
for _, r := range releases {
score := 0
switch r.Status {
case "Official":
score += 100
case "Promotion":
score += 50
}
if len(r.Media) > 0 {
switch r.Media[0].Format {
case "Digital Media":
score += 20
case "CD":
score += 15
}
}
if r.Barcode != "" {
score += 5
}
if score > bestScore {
bestScore = score
best = r
}
}
return best
}
func buildFuzzyArtistQuery(query string, similarity float64) string {
terms := strings.Fields(query)
if len(terms) == 0 {
return ""
}
var parts []string
for _, term := range terms {
escaped := escapeForFuzzy(term)
parts = append(parts, fmt.Sprintf("%s~%.1f", escaped, similarity))
}
termQuery := strings.Join(parts, " AND ")
return fmt.Sprintf("(artist:(%s) OR alias:(%s))", termQuery, termQuery)
}
func escapeForFuzzy(s string) string {
special := []string{`+`, `-`, `&`, `|`, `!`, `(`, `)`, `{`, `}`, `[`, `]`, `^`, `"`, `:`, `/`, `\`}
result := s
for _, char := range special {
result = strings.ReplaceAll(result, char, `\`+char)
}
return result
}
func escapeQuery(s string) string {
special := []string{`+`, `-`, `&`, `|`, `!`, `(`, `)`, `{`, `}`, `[`, `]`, `^`, `"`, `~`, `*`, `?`, `:`, `/`, `\`}
result := s
for _, char := range special {
result = strings.ReplaceAll(result, char, `\`+char)
}
return `"` + result + `"`
}
func decodeInto(data []byte, v any) error {
return json.Unmarshal(data, v)
}