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,161 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/metadata-agregator/internal/domain"
|
||||
metadatav1 "github.com/metadata-agregator/pkg/gen/metadata/v1"
|
||||
)
|
||||
|
||||
func toProtoArtist(d *domain.Artist) *metadatav1.Artist {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a := &metadatav1.Artist{
|
||||
Id: d.ID,
|
||||
Name: d.Name,
|
||||
SortName: d.SortName,
|
||||
ArtistType: d.Type,
|
||||
Country: d.Country,
|
||||
Description: d.Description,
|
||||
ImageUrl: d.ImageURL,
|
||||
}
|
||||
|
||||
if d.FormedDate != nil {
|
||||
a.FormedDate = d.FormedDate.Format("2006-01-02")
|
||||
}
|
||||
if d.DisbandedDate != nil {
|
||||
a.DisbandedDate = d.DisbandedDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
for _, g := range d.Genres {
|
||||
a.Genres = append(a.Genres, &metadatav1.Genre{
|
||||
Id: g.ID,
|
||||
Name: g.Name,
|
||||
})
|
||||
}
|
||||
|
||||
for _, e := range d.ExternalIDs {
|
||||
a.ExternalIds = append(a.ExternalIds, &metadatav1.ExternalID{
|
||||
Source: e.Source,
|
||||
SourceId: e.SourceID,
|
||||
Url: e.URL,
|
||||
})
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func toProtoAlbum(d *domain.Album) *metadatav1.Album {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a := &metadatav1.Album{
|
||||
Id: d.ID,
|
||||
Title: d.Title,
|
||||
AlbumType: d.Type,
|
||||
Upc: d.UPC,
|
||||
TotalTracks: int32(d.TotalTracks),
|
||||
TotalDiscs: int32(d.TotalDiscs),
|
||||
CoverUrl: d.CoverURL,
|
||||
}
|
||||
|
||||
if d.ReleaseDate != nil {
|
||||
a.ReleaseDate = d.ReleaseDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
for _, ac := range d.Artists {
|
||||
a.Artists = append(a.Artists, toProtoArtistCredit(&ac))
|
||||
}
|
||||
|
||||
if d.Label != nil {
|
||||
a.Label = &metadatav1.Label{
|
||||
Id: d.Label.ID,
|
||||
Name: d.Label.Name,
|
||||
Country: d.Label.Country,
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range d.Genres {
|
||||
a.Genres = append(a.Genres, &metadatav1.Genre{
|
||||
Id: g.ID,
|
||||
Name: g.Name,
|
||||
})
|
||||
}
|
||||
|
||||
for _, e := range d.ExternalIDs {
|
||||
a.ExternalIds = append(a.ExternalIds, &metadatav1.ExternalID{
|
||||
Source: e.Source,
|
||||
SourceId: e.SourceID,
|
||||
Url: e.URL,
|
||||
})
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func toProtoTrack(d *domain.Track) *metadatav1.Track {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
t := &metadatav1.Track{
|
||||
Id: d.ID,
|
||||
Title: d.Title,
|
||||
DurationMs: int32(d.DurationMs),
|
||||
Isrc: d.ISRC,
|
||||
Explicit: d.Explicit,
|
||||
DiscNumber: int32(d.DiscNumber),
|
||||
TrackNumber: int32(d.TrackNumber),
|
||||
}
|
||||
|
||||
for _, ac := range d.Artists {
|
||||
t.Artists = append(t.Artists, toProtoArtistCredit(&ac))
|
||||
}
|
||||
|
||||
if d.Work != nil {
|
||||
t.Work = toProtoWork(d.Work)
|
||||
}
|
||||
|
||||
for _, e := range d.ExternalIDs {
|
||||
t.ExternalIds = append(t.ExternalIds, &metadatav1.ExternalID{
|
||||
Source: e.Source,
|
||||
SourceId: e.SourceID,
|
||||
Url: e.URL,
|
||||
})
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func toProtoWork(d *domain.Work) *metadatav1.Work {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
w := &metadatav1.Work{
|
||||
Id: d.ID,
|
||||
Title: d.Title,
|
||||
WorkType: d.Type,
|
||||
Language: d.Language,
|
||||
}
|
||||
|
||||
for _, c := range d.Composers {
|
||||
w.Composers = append(w.Composers, toProtoArtistCredit(&c))
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func toProtoArtistCredit(d *domain.ArtistCredit) *metadatav1.ArtistCredit {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &metadatav1.ArtistCredit{
|
||||
Artist: toProtoArtist(&d.Artist),
|
||||
Role: d.Role,
|
||||
Position: int32(d.Position),
|
||||
JoinPhrase: d.JoinPhrase,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/metadata-agregator/internal/provider/musicbrainz"
|
||||
"github.com/metadata-agregator/internal/repository"
|
||||
"github.com/metadata-agregator/internal/service"
|
||||
metadatav1 "github.com/metadata-agregator/pkg/gen/metadata/v1"
|
||||
)
|
||||
|
||||
type MetadataServer struct {
|
||||
metadatav1.UnimplementedMetadataServiceServer
|
||||
services map[metadatav1.Provider]*service.MetadataService
|
||||
}
|
||||
|
||||
func NewMetadataServer(services map[metadatav1.Provider]*service.MetadataService) *MetadataServer {
|
||||
return &MetadataServer{services: services}
|
||||
}
|
||||
|
||||
func (s *MetadataServer) getService(p metadatav1.Provider) (*service.MetadataService, error) {
|
||||
if p == metadatav1.Provider_PROVIDER_UNSPECIFIED {
|
||||
p = metadatav1.Provider_PROVIDER_MUSICBRAINZ
|
||||
}
|
||||
|
||||
svc, ok := s.services[p]
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unknown provider: %v", p)
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetArtist(ctx context.Context, req *metadatav1.GetArtistRequest) (*metadatav1.Artist, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var id string
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetArtistRequest_Id:
|
||||
id = v.Id
|
||||
case *metadatav1.GetArtistRequest_External:
|
||||
id = v.External.SourceId
|
||||
default:
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
artist, err := svc.GetArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
|
||||
return toProtoArtist(artist), nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) SearchArtists(ctx context.Context, req *metadatav1.SearchArtistsRequest) (*metadatav1.SearchArtistsResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
result, err := svc.SearchArtists(ctx, req.Query, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.SearchArtistsResponse{
|
||||
Total: int32(result.Total),
|
||||
}
|
||||
|
||||
for _, a := range result.Items {
|
||||
resp.Artists = append(resp.Artists, toProtoArtist(&a))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetAlbum(ctx context.Context, req *metadatav1.GetAlbumRequest) (*metadatav1.Album, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var id string
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetAlbumRequest_Id:
|
||||
id = v.Id
|
||||
case *metadatav1.GetAlbumRequest_External:
|
||||
id = v.External.SourceId
|
||||
default:
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
album, err := svc.GetAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
|
||||
return toProtoAlbum(album), nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetArtistAlbums(ctx context.Context, req *metadatav1.GetArtistAlbumsRequest) (*metadatav1.GetArtistAlbumsResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
result, err := svc.GetArtistAlbums(ctx, req.ArtistId, limit, int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.GetArtistAlbumsResponse{
|
||||
Total: int32(result.Total),
|
||||
}
|
||||
|
||||
for _, a := range result.Items {
|
||||
resp.Albums = append(resp.Albums, toProtoAlbum(&a))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetTrack(ctx context.Context, req *metadatav1.GetTrackRequest) (*metadatav1.Track, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var track *metadatav1.Track
|
||||
|
||||
switch v := req.Identifier.(type) {
|
||||
case *metadatav1.GetTrackRequest_Id:
|
||||
t, err := svc.GetTrack(ctx, v.Id)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
case *metadatav1.GetTrackRequest_External:
|
||||
t, err := svc.GetTrack(ctx, v.External.SourceId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
case *metadatav1.GetTrackRequest_Isrc:
|
||||
t, err := svc.GetTrackByISRC(ctx, v.Isrc)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
track = toProtoTrack(t)
|
||||
|
||||
default:
|
||||
return nil, status.Error(codes.InvalidArgument, "identifier required")
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) GetAlbumTracks(ctx context.Context, req *metadatav1.GetAlbumTracksRequest) (*metadatav1.GetAlbumTracksResponse, error) {
|
||||
svc, err := s.getService(req.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracks, err := svc.GetAlbumTracks(ctx, req.AlbumId)
|
||||
if err != nil {
|
||||
return nil, toGRPCError(err)
|
||||
}
|
||||
|
||||
resp := &metadatav1.GetAlbumTracksResponse{}
|
||||
for _, t := range tracks {
|
||||
resp.Tracks = append(resp.Tracks, toProtoTrack(&t))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *MetadataServer) SyncArtist(ctx context.Context, req *metadatav1.SyncArtistRequest) (*metadatav1.SyncArtistResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "sync not yet implemented")
|
||||
}
|
||||
|
||||
func toGRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return status.Error(codes.NotFound, "not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrNotFound) {
|
||||
return status.Error(codes.NotFound, "not found")
|
||||
}
|
||||
|
||||
if errors.Is(err, musicbrainz.ErrRateLimited) {
|
||||
return status.Error(codes.ResourceExhausted, "rate limited")
|
||||
}
|
||||
|
||||
return status.Errorf(codes.Internal, "internal error: %v", err)
|
||||
}
|
||||
Reference in New Issue
Block a user