feat: initial implementation of metadata aggregator

- gRPC service with MusicBrainz provider
- PostgreSQL schema with migrations
- Service layer with database-first caching
- Repository pattern for data access
- YAML configuration support
- Research documentation for 17 music metadata projects
This commit is contained in:
Alexander
2026-04-28 16:27:14 +02:00
commit a1f6701bac
163 changed files with 95884 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
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) 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)
}