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:
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Name string `yaml:"name"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: 50051,
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
SSLMode: "disable",
|
||||
},
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
if d.Host == "" || d.User == "" || d.Name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name, d.SSLMode,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Artist struct {
|
||||
ID string
|
||||
Name string
|
||||
SortName string
|
||||
Type string
|
||||
Country string
|
||||
FormedDate *time.Time
|
||||
DisbandedDate *time.Time
|
||||
Description string
|
||||
ImageURL string
|
||||
Genres []Genre
|
||||
ExternalIDs []ExternalID
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID string
|
||||
Title string
|
||||
DurationMs int
|
||||
ISRC string
|
||||
Explicit bool
|
||||
DiscNumber int
|
||||
TrackNumber int
|
||||
Artists []ArtistCredit
|
||||
Work *Work
|
||||
ExternalIDs []ExternalID
|
||||
}
|
||||
|
||||
type Work struct {
|
||||
ID string
|
||||
Title string
|
||||
Type string
|
||||
Language string
|
||||
Composers []ArtistCredit
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID string
|
||||
Name string
|
||||
Country string
|
||||
}
|
||||
|
||||
type Genre struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type ArtistCredit struct {
|
||||
Artist Artist
|
||||
Role string
|
||||
Position int
|
||||
JoinPhrase string
|
||||
}
|
||||
|
||||
type ExternalID struct {
|
||||
Source string
|
||||
SourceID string
|
||||
URL string
|
||||
}
|
||||
|
||||
type SearchResult[T any] struct {
|
||||
Items []T
|
||||
Total int
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://musicbrainz.org/ws/2"
|
||||
userAgent = "MetadataAggregator/0.1.0 (https://github.com/metadata-agregator)"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
http *http.Client
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
func newClient() *client {
|
||||
return &client{
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
limiter: rate.NewLimiter(rate.Every(time.Second), 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) get(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, fmt.Errorf("rate limiter: %w", err)
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
params.Set("fmt", "json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s/%s?%s", baseURL, endpoint, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *client) lookup(ctx context.Context, entity, id string, inc []string) ([]byte, error) {
|
||||
params := url.Values{}
|
||||
if len(inc) > 0 {
|
||||
incStr := ""
|
||||
for i, v := range inc {
|
||||
if i > 0 {
|
||||
incStr += "+"
|
||||
}
|
||||
incStr += v
|
||||
}
|
||||
params.Set("inc", incStr)
|
||||
}
|
||||
|
||||
return c.get(ctx, fmt.Sprintf("%s/%s", entity, id), params)
|
||||
}
|
||||
|
||||
func (c *client) browse(ctx context.Context, entity, linkedEntity, linkedID string, limit, offset int, inc []string) ([]byte, error) {
|
||||
params := url.Values{}
|
||||
params.Set(linkedEntity, linkedID)
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
||||
|
||||
if len(inc) > 0 {
|
||||
incStr := ""
|
||||
for i, v := range inc {
|
||||
if i > 0 {
|
||||
incStr += "+"
|
||||
}
|
||||
incStr += v
|
||||
}
|
||||
params.Set("inc", incStr)
|
||||
}
|
||||
|
||||
return c.get(ctx, entity, params)
|
||||
}
|
||||
|
||||
func (c *client) search(ctx context.Context, entity, query string, limit, offset int) ([]byte, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
||||
|
||||
return c.get(ctx, entity, params)
|
||||
}
|
||||
|
||||
func decode[T any](data []byte) (*T, error) {
|
||||
var result T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package musicbrainz
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
)
|
||||
|
||||
func mapArtist(mb *mbArtist) *domain.Artist {
|
||||
if mb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
artist := &domain.Artist{
|
||||
ID: mb.ID,
|
||||
Name: mb.Name,
|
||||
SortName: mb.SortName,
|
||||
Type: mb.Type,
|
||||
Country: mb.Country,
|
||||
Description: mb.Disambiguation,
|
||||
ExternalIDs: []domain.ExternalID{{
|
||||
Source: "musicbrainz",
|
||||
SourceID: mb.ID,
|
||||
URL: fmt.Sprintf("https://musicbrainz.org/artist/%s", mb.ID),
|
||||
}},
|
||||
}
|
||||
|
||||
if mb.LifeSpan.Begin != "" {
|
||||
if t := parseDate(mb.LifeSpan.Begin); t != nil {
|
||||
artist.FormedDate = t
|
||||
}
|
||||
}
|
||||
|
||||
if mb.LifeSpan.End != "" {
|
||||
if t := parseDate(mb.LifeSpan.End); t != nil {
|
||||
artist.DisbandedDate = t
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range mb.Genres {
|
||||
artist.Genres = append(artist.Genres, domain.Genre{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
})
|
||||
}
|
||||
|
||||
for _, rel := range mb.Relations {
|
||||
if rel.Type == "image" && rel.URL != nil {
|
||||
artist.ImageURL = rel.URL.Resource
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return artist
|
||||
}
|
||||
|
||||
func mapAlbum(mb *mbReleaseGroup, release *mbRelease) *domain.Album {
|
||||
if mb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
album := &domain.Album{
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.PrimaryType,
|
||||
ExternalIDs: []domain.ExternalID{{
|
||||
Source: "musicbrainz",
|
||||
SourceID: mb.ID,
|
||||
URL: fmt.Sprintf("https://musicbrainz.org/release-group/%s", mb.ID),
|
||||
}},
|
||||
}
|
||||
|
||||
if mb.FirstReleaseDate != "" {
|
||||
album.ReleaseDate = parseDate(mb.FirstReleaseDate)
|
||||
}
|
||||
|
||||
for _, ac := range mb.ArtistCredit {
|
||||
album.Artists = append(album.Artists, mapArtistCredit(&ac, "primary"))
|
||||
}
|
||||
|
||||
for _, g := range mb.Genres {
|
||||
album.Genres = append(album.Genres, domain.Genre{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if release != nil {
|
||||
album.UPC = release.Barcode
|
||||
|
||||
if len(release.LabelInfo) > 0 && release.LabelInfo[0].Label != nil {
|
||||
album.Label = mapLabel(release.LabelInfo[0].Label)
|
||||
}
|
||||
|
||||
for _, m := range release.Media {
|
||||
album.TotalTracks += m.TrackCount
|
||||
}
|
||||
album.TotalDiscs = len(release.Media)
|
||||
|
||||
if release.CoverArtArchive.Front {
|
||||
album.CoverURL = fmt.Sprintf("https://coverartarchive.org/release/%s/front", release.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return album
|
||||
}
|
||||
|
||||
func mapTrack(mb *mbRecording, discNum, trackNum int) *domain.Track {
|
||||
if mb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
track := &domain.Track{
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
DurationMs: mb.Length,
|
||||
DiscNumber: discNum,
|
||||
TrackNumber: trackNum,
|
||||
ExternalIDs: []domain.ExternalID{{
|
||||
Source: "musicbrainz",
|
||||
SourceID: mb.ID,
|
||||
URL: fmt.Sprintf("https://musicbrainz.org/recording/%s", mb.ID),
|
||||
}},
|
||||
}
|
||||
|
||||
if len(mb.ISRCs) > 0 {
|
||||
track.ISRC = mb.ISRCs[0]
|
||||
}
|
||||
|
||||
for _, ac := range mb.ArtistCredit {
|
||||
track.Artists = append(track.Artists, mapArtistCredit(&ac, "primary"))
|
||||
}
|
||||
|
||||
for _, rel := range mb.Relations {
|
||||
if rel.TargetType == "work" && rel.Work != nil {
|
||||
track.Work = mapWork(rel.Work)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func mapWork(mb *mbWork) *domain.Work {
|
||||
if mb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
work := &domain.Work{
|
||||
ID: mb.ID,
|
||||
Title: mb.Title,
|
||||
Type: mb.Type,
|
||||
Language: mb.Language,
|
||||
}
|
||||
|
||||
for _, rel := range mb.Relations {
|
||||
if rel.TargetType == "artist" && rel.Artist != nil {
|
||||
role := "writer"
|
||||
if rel.Type == "composer" || rel.Type == "lyricist" || rel.Type == "writer" {
|
||||
role = rel.Type
|
||||
}
|
||||
work.Composers = append(work.Composers, domain.ArtistCredit{
|
||||
Artist: *mapArtist(rel.Artist),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return work
|
||||
}
|
||||
|
||||
func mapLabel(mb *mbLabel) *domain.Label {
|
||||
if mb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &domain.Label{
|
||||
ID: mb.ID,
|
||||
Name: mb.Name,
|
||||
Country: mb.Country,
|
||||
}
|
||||
}
|
||||
|
||||
func mapArtistCredit(ac *mbArtistCredit, defaultRole string) domain.ArtistCredit {
|
||||
credit := domain.ArtistCredit{
|
||||
Role: defaultRole,
|
||||
JoinPhrase: ac.JoinPhrase,
|
||||
}
|
||||
|
||||
if ac.Artist != nil {
|
||||
credit.Artist = *mapArtist(ac.Artist)
|
||||
}
|
||||
|
||||
return credit
|
||||
}
|
||||
|
||||
func parseDate(s string) *time.Time {
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
}
|
||||
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type mbLifeSpan struct {
|
||||
Begin string `json:"begin"`
|
||||
End string `json:"end"`
|
||||
Ended bool `json:"ended"`
|
||||
}
|
||||
|
||||
type mbReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
Genres []mbGenre `json:"genres"`
|
||||
Releases []*mbRelease `json:"releases"`
|
||||
}
|
||||
|
||||
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"`
|
||||
CoverArtArchive mbCoverArtArchive `json:"cover-art-archive"`
|
||||
}
|
||||
|
||||
type mbCoverArtArchive struct {
|
||||
Artwork bool `json:"artwork"`
|
||||
Front bool `json:"front"`
|
||||
Back bool `json:"back"`
|
||||
}
|
||||
|
||||
type mbLabelInfo struct {
|
||||
CatalogNumber string `json:"catalog-number"`
|
||||
Label *mbLabel `json:"label"`
|
||||
}
|
||||
|
||||
type mbLabel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type mbMedium struct {
|
||||
Position int `json:"position"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"track-count"`
|
||||
Tracks []mbTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type mbTrack struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
Position int `json:"position"`
|
||||
Recording mbRecording `json:"recording"`
|
||||
}
|
||||
|
||||
type mbRecording struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
ISRCs []string `json:"isrcs"`
|
||||
ArtistCredit []mbArtistCredit `json:"artist-credit"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
}
|
||||
|
||||
type mbWork struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Language string `json:"language"`
|
||||
ISWCs []string `json:"iswcs"`
|
||||
Relations []mbRelation `json:"relations"`
|
||||
}
|
||||
|
||||
type mbArtistCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
Artist *mbArtist `json:"artist"`
|
||||
}
|
||||
|
||||
type mbGenre struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type mbRelation struct {
|
||||
Type string `json:"type"`
|
||||
TypeID string `json:"type-id"`
|
||||
Direction string `json:"direction"`
|
||||
TargetType string `json:"target-type"`
|
||||
URL *mbURL `json:"url"`
|
||||
Artist *mbArtist `json:"artist"`
|
||||
Work *mbWork `json:"work"`
|
||||
Attributes []string `json:"attributes"`
|
||||
}
|
||||
|
||||
type mbURL struct {
|
||||
ID string `json:"id"`
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
type mbSearchResponse[T any] struct {
|
||||
Created string `json:"created"`
|
||||
Count int `json:"count"`
|
||||
Offset int `json:"offset"`
|
||||
Artists []T `json:"artists,omitempty"`
|
||||
}
|
||||
|
||||
type mbBrowseResponse[T any] struct {
|
||||
ReleaseGroupCount int `json:"release-group-count"`
|
||||
ReleaseGroupOffset int `json:"release-group-offset"`
|
||||
ReleaseGroups []T `json:"release-groups,omitempty"`
|
||||
ReleaseCount int `json:"release-count"`
|
||||
ReleaseOffset int `json:"release-offset"`
|
||||
Releases []T `json:"releases,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
|
||||
GetArtist(ctx context.Context, id string) (*domain.Artist, error)
|
||||
SearchArtists(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error)
|
||||
|
||||
GetAlbum(ctx context.Context, id string) (*domain.Album, error)
|
||||
GetArtistAlbums(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error)
|
||||
|
||||
GetTrack(ctx context.Context, id string) (*domain.Track, error)
|
||||
GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error)
|
||||
GetTrackByISRC(ctx context.Context, isrc string) (*domain.Track, error)
|
||||
|
||||
GetLabel(ctx context.Context, id string) (*domain.Label, error)
|
||||
|
||||
GetWork(ctx context.Context, id string) (*domain.Work, error)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package repository
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -0,0 +1,238 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
|
||||
type AlbumRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
||||
return &AlbumRepository{pool: pool}
|
||||
}
|
||||
|
||||
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
|
||||
FROM albums
|
||||
WHERE id = $1`
|
||||
|
||||
album, err := r.scanAlbum(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadRelations(ctx, album); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
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
|
||||
FROM albums a
|
||||
JOIN album_external_ids e ON a.id = e.album_id
|
||||
WHERE e.source = $1 AND e.source_id = $2`
|
||||
|
||||
album, err := r.scanAlbum(ctx, query, source, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadRelations(ctx, album); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.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`
|
||||
|
||||
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
|
||||
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
|
||||
LIMIT $2 OFFSET $3`
|
||||
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, countQuery, artistID).Scan(&total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, searchQuery, artistID, limit, offset)
|
||||
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
|
||||
}
|
||||
albums = append(albums, *album)
|
||||
}
|
||||
|
||||
return &domain.SearchResult[domain.Album]{
|
||||
Items: albums,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
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, release_date, upc, total_tracks,
|
||||
total_discs, cover_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
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.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) scanAlbum(ctx context.Context, query string, args ...any) (*domain.Album, error) {
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
return r.scanAlbumRow(row)
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) scanAlbumFromRow(row pgx.Row) (*domain.Album, error) {
|
||||
return r.scanAlbumRow(row)
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) scanAlbumRow(row pgx.Row) (*domain.Album, error) {
|
||||
var (
|
||||
album domain.Album
|
||||
albumType *string
|
||||
releaseDate *time.Time
|
||||
upc *string
|
||||
totalTracks *int
|
||||
totalDiscs *int
|
||||
coverURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&album.ID, &album.Title, &albumType, &releaseDate, &upc,
|
||||
&totalTracks, &totalDiscs, &coverURL, &source, &sourceID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
album.Type = derefString(albumType)
|
||||
album.ReleaseDate = releaseDate
|
||||
album.UPC = derefString(upc)
|
||||
if totalTracks != nil {
|
||||
album.TotalTracks = *totalTracks
|
||||
}
|
||||
if totalDiscs != nil {
|
||||
album.TotalDiscs = *totalDiscs
|
||||
}
|
||||
album.CoverURL = derefString(coverURL)
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func (r *AlbumRepository) loadRelations(ctx context.Context, album *domain.Album) error {
|
||||
extQuery := `SELECT source, source_id, url FROM album_external_ids WHERE album_id = $1`
|
||||
rows, err := r.pool.Query(ctx, extQuery, album.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var ext domain.ExternalID
|
||||
var url *string
|
||||
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
||||
return err
|
||||
}
|
||||
ext.URL = derefString(url)
|
||||
album.ExternalIDs = append(album.ExternalIDs, ext)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
|
||||
type ArtistRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
|
||||
return &ArtistRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*domain.Artist, error) {
|
||||
query := `
|
||||
SELECT id, name, sort_name, artist_type, country, formed_date, disbanded_date,
|
||||
description, image_url, source, source_id
|
||||
FROM artists
|
||||
WHERE id = $1`
|
||||
|
||||
artist, err := r.scanArtist(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadExternalIDs(ctx, artist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Artist, error) {
|
||||
query := `
|
||||
SELECT a.id, a.name, a.sort_name, a.artist_type, a.country, a.formed_date,
|
||||
a.disbanded_date, a.description, a.image_url, a.source, a.source_id
|
||||
FROM artists a
|
||||
JOIN artist_external_ids e ON a.id = e.artist_id
|
||||
WHERE e.source = $1 AND e.source_id = $2`
|
||||
|
||||
artist, err := r.scanArtist(ctx, query, source, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadExternalIDs(ctx, artist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
||||
countQuery := `SELECT COUNT(*) FROM artists WHERE name ILIKE $1`
|
||||
searchQuery := `
|
||||
SELECT id, name, sort_name, artist_type, country, formed_date, disbanded_date,
|
||||
description, image_url, source, source_id
|
||||
FROM artists
|
||||
WHERE name ILIKE $1
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3`
|
||||
|
||||
pattern := "%" + query + "%"
|
||||
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, countQuery, pattern).Scan(&total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, searchQuery, pattern, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var artists []domain.Artist
|
||||
for rows.Next() {
|
||||
artist, err := r.scanArtistFromRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artists = append(artists, *artist)
|
||||
}
|
||||
|
||||
return &domain.SearchResult[domain.Artist]{
|
||||
Items: artists,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) Save(ctx context.Context, artist *domain.Artist) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var source, sourceID string
|
||||
if len(artist.ExternalIDs) > 0 {
|
||||
source = artist.ExternalIDs[0].Source
|
||||
sourceID = artist.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO artists (id, name, sort_name, artist_type, country, formed_date,
|
||||
disbanded_date, description, image_url, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
sort_name = EXCLUDED.sort_name,
|
||||
artist_type = EXCLUDED.artist_type,
|
||||
country = EXCLUDED.country,
|
||||
formed_date = EXCLUDED.formed_date,
|
||||
disbanded_date = EXCLUDED.disbanded_date,
|
||||
description = EXCLUDED.description,
|
||||
image_url = EXCLUDED.image_url,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
artist.ID, artist.Name, nullString(artist.SortName), nullString(artist.Type),
|
||||
nullString(artist.Country), artist.FormedDate, artist.DisbandedDate,
|
||||
nullString(artist.Description), nullString(artist.ImageURL), source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range artist.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO artist_external_ids (artist_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (artist_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, artist.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) scanArtist(ctx context.Context, query string, args ...any) (*domain.Artist, error) {
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
|
||||
var (
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&artist.ID, &artist.Name, &sortName, &artistType, &country,
|
||||
&formedDate, &disbandDate, &description, &imageURL, &source, &sourceID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artist.SortName = derefString(sortName)
|
||||
artist.Type = derefString(artistType)
|
||||
artist.Country = derefString(country)
|
||||
artist.FormedDate = formedDate
|
||||
artist.DisbandedDate = disbandDate
|
||||
artist.Description = derefString(description)
|
||||
artist.ImageURL = derefString(imageURL)
|
||||
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) scanArtistFromRow(row pgx.Row) (*domain.Artist, error) {
|
||||
var (
|
||||
artist domain.Artist
|
||||
sortName *string
|
||||
artistType *string
|
||||
country *string
|
||||
formedDate *time.Time
|
||||
disbandDate *time.Time
|
||||
description *string
|
||||
imageURL *string
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&artist.ID, &artist.Name, &sortName, &artistType, &country,
|
||||
&formedDate, &disbandDate, &description, &imageURL, &source, &sourceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artist.SortName = derefString(sortName)
|
||||
artist.Type = derefString(artistType)
|
||||
artist.Country = derefString(country)
|
||||
artist.FormedDate = formedDate
|
||||
artist.DisbandedDate = disbandDate
|
||||
artist.Description = derefString(description)
|
||||
artist.ImageURL = derefString(imageURL)
|
||||
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
func (r *ArtistRepository) loadExternalIDs(ctx context.Context, artist *domain.Artist) error {
|
||||
query := `SELECT source, source_id, url FROM artist_external_ids WHERE artist_id = $1`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, artist.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var ext domain.ExternalID
|
||||
var url *string
|
||||
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
||||
return err
|
||||
}
|
||||
ext.URL = derefString(url)
|
||||
artist.ExternalIDs = append(artist.ExternalIDs, ext)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func nullString(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
|
||||
type TrackRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTrackRepository(pool *pgxpool.Pool) *TrackRepository {
|
||||
return &TrackRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *TrackRepository) GetByID(ctx context.Context, id string) (*domain.Track, error) {
|
||||
query := `
|
||||
SELECT id, title, duration_ms, isrc, explicit, source, source_id
|
||||
FROM tracks
|
||||
WHERE id = $1`
|
||||
|
||||
track, err := r.scanTrack(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadExternalIDs(ctx, track); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (r *TrackRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Track, error) {
|
||||
query := `
|
||||
SELECT t.id, t.title, t.duration_ms, t.isrc, t.explicit, t.source, t.source_id
|
||||
FROM tracks t
|
||||
JOIN track_external_ids e ON t.id = e.track_id
|
||||
WHERE e.source = $1 AND e.source_id = $2`
|
||||
|
||||
track, err := r.scanTrack(ctx, query, source, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadExternalIDs(ctx, track); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (r *TrackRepository) GetByISRC(ctx context.Context, isrc string) (*domain.Track, error) {
|
||||
query := `
|
||||
SELECT id, title, duration_ms, isrc, explicit, source, source_id
|
||||
FROM tracks
|
||||
WHERE isrc = $1`
|
||||
|
||||
track, err := r.scanTrack(ctx, query, isrc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.loadExternalIDs(ctx, track); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (r *TrackRepository) GetByAlbumID(ctx context.Context, albumID string) ([]domain.Track, error) {
|
||||
query := `
|
||||
SELECT t.id, t.title, t.duration_ms, t.isrc, t.explicit, t.source, t.source_id,
|
||||
at.disc_number, at.track_number
|
||||
FROM tracks t
|
||||
JOIN album_tracks at ON t.id = at.track_id
|
||||
JOIN album_external_ids ae ON at.album_id = ae.album_id
|
||||
WHERE ae.source_id = $1
|
||||
ORDER BY at.disc_number, at.track_number`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tracks []domain.Track
|
||||
for rows.Next() {
|
||||
var (
|
||||
track domain.Track
|
||||
durationMs *int
|
||||
isrc *string
|
||||
explicit *bool
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&track.ID, &track.Title, &durationMs, &isrc, &explicit,
|
||||
&source, &sourceID, &track.DiscNumber, &track.TrackNumber,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if durationMs != nil {
|
||||
track.DurationMs = *durationMs
|
||||
}
|
||||
track.ISRC = derefString(isrc)
|
||||
if explicit != nil {
|
||||
track.Explicit = *explicit
|
||||
}
|
||||
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
|
||||
return tracks, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TrackRepository) Save(ctx context.Context, track *domain.Track) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var source, sourceID string
|
||||
if len(track.ExternalIDs) > 0 {
|
||||
source = track.ExternalIDs[0].Source
|
||||
sourceID = track.ExternalIDs[0].SourceID
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO tracks (id, title, duration_ms, isrc, explicit, source, source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
isrc = EXCLUDED.isrc,
|
||||
explicit = EXCLUDED.explicit,
|
||||
updated_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, query,
|
||||
track.ID, track.Title, track.DurationMs, nullString(track.ISRC),
|
||||
track.Explicit, source, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ext := range track.ExternalIDs {
|
||||
extQuery := `
|
||||
INSERT INTO track_external_ids (track_id, source, source_id, url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (track_id, source, source_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
fetched_at = now()`
|
||||
|
||||
_, err = tx.Exec(ctx, extQuery, track.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *TrackRepository) scanTrack(ctx context.Context, query string, args ...any) (*domain.Track, error) {
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
|
||||
var (
|
||||
track domain.Track
|
||||
durationMs *int
|
||||
isrc *string
|
||||
explicit *bool
|
||||
source string
|
||||
sourceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&track.ID, &track.Title, &durationMs, &isrc, &explicit, &source, &sourceID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if durationMs != nil {
|
||||
track.DurationMs = *durationMs
|
||||
}
|
||||
track.ISRC = derefString(isrc)
|
||||
if explicit != nil {
|
||||
track.Explicit = *explicit
|
||||
}
|
||||
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (r *TrackRepository) loadExternalIDs(ctx context.Context, track *domain.Track) error {
|
||||
query := `SELECT source, source_id, url FROM track_external_ids WHERE track_id = $1`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, track.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var ext domain.ExternalID
|
||||
var url *string
|
||||
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
||||
return err
|
||||
}
|
||||
ext.URL = derefString(url)
|
||||
track.ExternalIDs = append(track.ExternalIDs, ext)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
)
|
||||
|
||||
type ArtistRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.Artist, error)
|
||||
GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Artist, error)
|
||||
Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error)
|
||||
Save(ctx context.Context, artist *domain.Artist) error
|
||||
}
|
||||
|
||||
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)
|
||||
Save(ctx context.Context, album *domain.Album) error
|
||||
}
|
||||
|
||||
type TrackRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.Track, error)
|
||||
GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Track, error)
|
||||
GetByISRC(ctx context.Context, isrc string) (*domain.Track, error)
|
||||
GetByAlbumID(ctx context.Context, albumID string) ([]domain.Track, error)
|
||||
Save(ctx context.Context, track *domain.Track) error
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
"github.com/metadata-agregator/internal/provider"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
)
|
||||
|
||||
type MetadataService struct {
|
||||
artists repository.ArtistRepository
|
||||
albums repository.AlbumRepository
|
||||
tracks repository.TrackRepository
|
||||
provider provider.Provider
|
||||
}
|
||||
|
||||
func NewMetadataService(
|
||||
artists repository.ArtistRepository,
|
||||
albums repository.AlbumRepository,
|
||||
tracks repository.TrackRepository,
|
||||
prov provider.Provider,
|
||||
) *MetadataService {
|
||||
return &MetadataService{
|
||||
artists: artists,
|
||||
albums: albums,
|
||||
tracks: tracks,
|
||||
provider: prov,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetArtist(ctx context.Context, id string) (*domain.Artist, error) {
|
||||
artist, err := s.artists.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
return artist, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artist, err = s.provider.GetArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.artists.Save(ctx, artist); saveErr != nil {
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) SearchArtists(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
||||
result, err := s.artists.Search(ctx, query, limit, offset)
|
||||
if err == nil && len(result.Items) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return s.provider.SearchArtists(ctx, query, limit, offset)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAlbum(ctx context.Context, id string) (*domain.Album, error) {
|
||||
album, err := s.albums.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
return album, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
album, err = s.provider.GetAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.albums.Save(ctx, album); saveErr != nil {
|
||||
return album, nil
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
||||
result, err := s.albums.GetByArtistID(ctx, artistID, limit, offset)
|
||||
if err == nil && len(result.Items) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return s.provider.GetArtistAlbums(ctx, artistID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetTrack(ctx context.Context, id string) (*domain.Track, error) {
|
||||
track, err := s.tracks.GetByExternalID(ctx, s.provider.Name(), id)
|
||||
if err == nil {
|
||||
return track, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track, err = s.provider.GetTrack(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.tracks.Save(ctx, track); saveErr != nil {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetTrackByISRC(ctx context.Context, isrc string) (*domain.Track, error) {
|
||||
track, err := s.tracks.GetByISRC(ctx, isrc)
|
||||
if err == nil {
|
||||
return track, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track, err = s.provider.GetTrackByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveErr := s.tracks.Save(ctx, track); saveErr != nil {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAlbumTracks(ctx context.Context, albumID string) ([]domain.Track, error) {
|
||||
tracks, err := s.tracks.GetByAlbumID(ctx, albumID)
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
return s.provider.GetAlbumTracks(ctx, albumID)
|
||||
}
|
||||
Reference in New Issue
Block a user