326 lines
7.4 KiB
Go
326 lines
7.4 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
|
|
}
|
|
|
|
escapedQuery := escapeQuery(query)
|
|
data, err := p.client.search(ctx, "artist", fmt.Sprintf("artist:%s", escapedQuery), 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, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 25
|
|
}
|
|
|
|
data, err := p.client.browse(ctx, "release-group", "artist", artistID, limit, 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
|
|
}
|
|
|
|
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 {
|
|
result.Items = append(result.Items, *album)
|
|
}
|
|
}
|
|
|
|
return result, 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 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)
|
|
}
|