diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 8fad19975..06b5dd247 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -44,7 +44,7 @@ func CreateSubsonicAPIRouter() *subsonic.Router { players := core.NewPlayers(dataStore) client := core.LastFMNewClient() spotifyClient := core.SpotifyNewClient() - externalInfo := core.NewExternalInfo(dataStore, client, spotifyClient) + externalInfo := core.NewExternalInfo2(dataStore, client, spotifyClient) scanner := GetScanner() router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalInfo, scanner) return router diff --git a/conf/configuration.go b/conf/configuration.go index f0ec0d152..1d51f8f40 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -44,6 +44,8 @@ type configOptions struct { AuthWindowLength time.Duration Scanner scannerOptions + + Agents string LastFM lastfmOptions Spotify spotifyOptions @@ -71,7 +73,10 @@ type spotifyOptions struct { Secret string } -var Server = &configOptions{} +var ( + Server = &configOptions{} + hooks []func() +) func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) @@ -99,6 +104,16 @@ func Load() { if log.CurrentLevel() >= log.LevelDebug { pretty.Printf("Loaded configuration from '%s': %# v\n", Server.ConfigFile, Server) } + + // Call init hooks + for _, hook := range hooks { + hook() + } +} + +// AddHook is used to register initialization code that should run as soon as the config is loaded +func AddHook(hook func()) { + hooks = append(hooks, hook) } func init() { @@ -131,6 +146,7 @@ func init() { viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("scanner.extractor", "taglib") + viper.SetDefault("agents", "lastfm,spotify") viper.SetDefault("lastfm.language", "en") viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") diff --git a/core/agents/agents.go b/core/agents/agents.go new file mode 100644 index 000000000..6deb01093 --- /dev/null +++ b/core/agents/agents.go @@ -0,0 +1,62 @@ +package agents + +import ( + "context" + "errors" +) + +type Constructor func(ctx context.Context) Interface + +type Interface interface{} + +type Artist struct { + Name string + MBID string +} + +type ArtistImage struct { + URL string + Size int +} + +type Track struct { + Name string + MBID string +} + +var ( + ErrNotFound = errors.New("not found") +) + +type ArtistMBIDRetriever interface { + GetMBID(name string) (string, error) +} + +type ArtistURLRetriever interface { + GetURL(name, mbid string) (string, error) +} + +type ArtistBiographyRetriever interface { + GetBiography(name, mbid string) (string, error) +} + +type ArtistSimilarRetriever interface { + GetSimilar(name, mbid string, limit int) ([]Artist, error) +} + +type ArtistImageRetriever interface { + GetImages(name, mbid string) ([]ArtistImage, error) +} + +type ArtistTopSongsRetriever interface { + GetTopSongs(artistName, mbid string, count int) ([]Track, error) +} + +var Map map[string]Constructor + +func Register(name string, init Constructor) { + if Map == nil { + Map = make(map[string]Constructor) + } + Map[name] = init +} diff --git a/core/agents/lastfm.go b/core/agents/lastfm.go new file mode 100644 index 000000000..881eed080 --- /dev/null +++ b/core/agents/lastfm.go @@ -0,0 +1,137 @@ +package agents + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/lastfm" + "github.com/navidrome/navidrome/log" +) + +type lastfmAgent struct { + ctx context.Context + apiKey string + lang string + client *lastfm.Client +} + +func lastFMConstructor(ctx context.Context) Interface { + if conf.Server.LastFM.ApiKey == "" { + return nil + } + + l := &lastfmAgent{ + ctx: ctx, + apiKey: conf.Server.LastFM.ApiKey, + lang: conf.Server.LastFM.Language, + } + l.client = lastfm.NewClient(l.apiKey, l.lang, http.DefaultClient) + return l +} + +func (l *lastfmAgent) GetMBID(name string) (string, error) { + a, err := l.callArtistGetInfo(name, "") + if err != nil { + return "", err + } + if a.MBID == "" { + return "", ErrNotFound + } + return a.MBID, nil +} + +func (l *lastfmAgent) GetURL(name, mbid string) (string, error) { + a, err := l.callArtistGetInfo(name, mbid) + if err != nil { + return "", err + } + if a.URL == "" { + return "", ErrNotFound + } + return a.URL, nil + +} + +func (l *lastfmAgent) GetBiography(name, mbid string) (string, error) { + a, err := l.callArtistGetInfo(name, mbid) + if err != nil { + return "", err + } + if a.Bio.Summary == "" { + return "", ErrNotFound + } + return a.Bio.Summary, nil +} + +func (l *lastfmAgent) GetSimilar(name, mbid string, limit int) ([]Artist, error) { + resp, err := l.callArtistGetSimilar(name, mbid, limit) + if err != nil { + return nil, err + } + if len(resp) == 0 { + return nil, ErrNotFound + } + var res []Artist + for _, a := range resp { + res = append(res, Artist{ + Name: a.Name, + MBID: a.MBID, + }) + } + return res, nil +} + +func (l *lastfmAgent) GetTopSongs(artistName, mbid string, count int) ([]Track, error) { + resp, err := l.callArtistGetTopTracks(artistName, mbid, count) + if err != nil { + return nil, err + } + if len(resp) == 0 { + return nil, ErrNotFound + } + var res []Track + for _, t := range resp { + res = append(res, Track{ + Name: t.Name, + MBID: t.MBID, + }) + } + return res, nil +} + +func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artist, error) { + a, err := l.client.ArtistGetInfo(l.ctx, name) + if err != nil { + log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid) + return nil, err + } + return a, nil +} + +func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) ([]lastfm.Artist, error) { + s, err := l.client.ArtistGetSimilar(l.ctx, name, limit) + if err != nil { + log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid) + return nil, err + } + return s, nil +} + +func (l *lastfmAgent) callArtistGetTopTracks(artistName, mbid string, count int) ([]lastfm.Track, error) { + t, err := l.client.ArtistGetTopTracks(l.ctx, artistName, count) + if err != nil { + log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid) + return nil, err + } + return t, nil +} + +func init() { + conf.AddHook(func() { + if conf.Server.LastFM.ApiKey != "" { + log.Info("Last.FM integration is ENABLED") + Register("lastfm", lastFMConstructor) + } + }) +} diff --git a/core/agents/spotify.go b/core/agents/spotify.go new file mode 100644 index 000000000..f5fa330c4 --- /dev/null +++ b/core/agents/spotify.go @@ -0,0 +1,87 @@ +package agents + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/spotify" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/xrash/smetrics" +) + +type spotifyAgent struct { + ctx context.Context + id string + secret string + client *spotify.Client +} + +func spotifyConstructor(ctx context.Context) Interface { + if conf.Server.Spotify.ID == "" { + return nil + } + + l := &spotifyAgent{ + ctx: ctx, + id: conf.Server.Spotify.ID, + secret: conf.Server.Spotify.Secret, + } + l.client = spotify.NewClient(l.id, l.secret, http.DefaultClient) + return l +} + +func (s *spotifyAgent) GetImages(name, mbid string) ([]ArtistImage, error) { + a, err := s.searchArtist(name) + if err != nil { + if err == model.ErrNotFound { + log.Warn(s.ctx, "Artist not found in Spotify", "artist", name) + } else { + log.Error(s.ctx, "Error calling Spotify", "artist", name, err) + } + return nil, err + } + + var res []ArtistImage + for _, img := range a.Images { + res = append(res, ArtistImage{ + URL: img.URL, + Size: img.Width, + }) + } + return res, nil +} + +func (s *spotifyAgent) searchArtist(name string) (*spotify.Artist, error) { + artists, err := s.client.SearchArtists(s.ctx, name, 40) + if err != nil || len(artists) == 0 { + return nil, model.ErrNotFound + } + name = strings.ToLower(name) + + // Sort results, prioritizing artists with images, with similar names and with high popularity, in this order + sort.Slice(artists, func(i, j int) bool { + ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity) + aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity) + return ai < aj + }) + + // If the first one has the same name, that's the one + if strings.ToLower(artists[0].Name) != name { + return nil, model.ErrNotFound + } + return &artists[0], err +} + +func init() { + conf.AddHook(func() { + if conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" { + log.Info("Spotify integration is ENABLED") + Register("spotify", spotifyConstructor) + } + }) +} diff --git a/core/external_info.go b/core/external_info.go index c8fb0e3cf..9a314738d 100644 --- a/core/external_info.go +++ b/core/external_info.go @@ -38,7 +38,7 @@ type externalInfo struct { spf *spotify.Client } -const UnavailableArtistID = "-1" +const unavailableArtistID = "-1" func (e *externalInfo) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) { artist, err := e.getArtist(ctx, id) @@ -78,7 +78,7 @@ func (e *externalInfo) UpdateArtistInfo(ctx context.Context, id string, count in similar := artist.SimilarArtists artist.SimilarArtists = nil for _, s := range similar { - if s.ID == UnavailableArtistID { + if s.ID == unavailableArtistID { continue } artist.SimilarArtists = append(artist.SimilarArtists, s) @@ -170,7 +170,7 @@ func (e *externalInfo) similarArtists(ctx context.Context, artistName string, co // Then fill up with non-present artists if includeNotPresent { for _, s := range notPresent { - sa := model.Artist{ID: UnavailableArtistID, Name: s} + sa := model.Artist{ID: unavailableArtistID, Name: s} result = append(result, sa) } } @@ -382,7 +382,7 @@ func (e *externalInfo) setLargeImageUrl(artist *model.Artist, url string) { func (e *externalInfo) loadSimilar(ctx context.Context, artist *model.Artist, includeNotPresent bool) error { var ids []string for _, sa := range artist.SimilarArtists { - if sa.ID == UnavailableArtistID { + if sa.ID == unavailableArtistID { continue } ids = append(ids, sa.ID) @@ -409,7 +409,7 @@ func (e *externalInfo) loadSimilar(ctx context.Context, artist *model.Artist, in continue } la = sa - la.ID = UnavailableArtistID + la.ID = unavailableArtistID } loaded = append(loaded, la) } diff --git a/core/external_info2.go b/core/external_info2.go new file mode 100644 index 000000000..0874b0bbf --- /dev/null +++ b/core/external_info2.go @@ -0,0 +1,262 @@ +package core + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lastfm" + "github.com/navidrome/navidrome/core/spotify" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type externalInfo2 struct { + ds model.DataStore +} + +func NewExternalInfo2(ds model.DataStore, lfm *lastfm.Client, spf *spotify.Client) ExternalInfo { + return &externalInfo2{ds: ds} +} + +func (e *externalInfo2) initAgents(ctx context.Context) []agents.Interface { + order := strings.Split(conf.Server.Agents, ",") + var res []agents.Interface + for _, name := range order { + init, ok := agents.Map[name] + if !ok { + log.Error(ctx, "Agent not available. Check configuration", "name", name) + continue + } + + res = append(res, init(ctx)) + } + + return res +} + +func (e *externalInfo2) getArtist(ctx context.Context, id string) (*model.Artist, error) { + var entity interface{} + entity, err := GetEntityByID(ctx, e.ds, id) + if err != nil { + return nil, err + } + + switch v := entity.(type) { + case *model.Artist: + return v, nil + case *model.MediaFile: + return e.ds.Artist(ctx).Get(v.ArtistID) + case *model.Album: + return e.ds.Artist(ctx).Get(v.AlbumArtistID) + } + return nil, model.ErrNotFound +} + +func (e *externalInfo2) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) { + allAgents := e.initAgents(ctx) + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + // TODO Uncomment + // If we have updated info, just return it + //if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive { + // log.Debug("Found cached ArtistInfo", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name) + // err := e.loadSimilar(ctx, artist, includeNotPresent) + // return artist, err + //} + log.Debug("ArtistInfo not cached", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id) + + wg := sync.WaitGroup{} + e.callGetMBID(ctx, allAgents, artist, &wg) + e.callGetBiography(ctx, allAgents, artist, &wg) + e.callGetURL(ctx, allAgents, artist, &wg) + e.callGetSimilar(ctx, allAgents, artist, similarCount, &wg) + // TODO Images + wg.Wait() + + artist.ExternalInfoUpdatedAt = time.Now() + err = e.ds.Artist(ctx).Put(artist) + if err != nil { + log.Error(ctx, "Error trying to update artistImageUrl", "id", id, err) + } + + if !includeNotPresent { + similar := artist.SimilarArtists + artist.SimilarArtists = nil + for _, s := range similar { + if s.ID == unavailableArtistID { + continue + } + artist.SimilarArtists = append(artist.SimilarArtists, s) + } + } + + log.Trace(ctx, "ArtistInfo collected", "artist", artist) + + return artist, nil +} + +func (e *externalInfo2) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + return nil, nil +} + +func (e *externalInfo2) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { + return nil, nil +} + +func (e *externalInfo2) callGetMBID(ctx context.Context, allAgents []agents.Interface, artist *model.Artist, wg *sync.WaitGroup) { + wg.Add(1) + go func() { + defer wg.Done() + for _, a := range allAgents { + agent, ok := a.(agents.ArtistMBIDRetriever) + if !ok { + continue + } + mbid, err := agent.GetMBID(artist.Name) + if mbid != "" && err == nil { + artist.MbzArtistID = mbid + } + } + }() +} + +func (e *externalInfo2) callGetURL(ctx context.Context, allAgents []agents.Interface, artist *model.Artist, wg *sync.WaitGroup) { + wg.Add(1) + go func() { + defer wg.Done() + for _, a := range allAgents { + agent, ok := a.(agents.ArtistURLRetriever) + if !ok { + continue + } + url, err := agent.GetURL(artist.Name, artist.MbzArtistID) + if url != "" && err == nil { + artist.ExternalUrl = url + } + } + }() +} + +func (e *externalInfo2) callGetBiography(ctx context.Context, allAgents []agents.Interface, artist *model.Artist, wg *sync.WaitGroup) { + wg.Add(1) + go func() { + defer wg.Done() + for _, a := range allAgents { + agent, ok := a.(agents.ArtistBiographyRetriever) + if !ok { + continue + } + bio, err := agent.GetBiography(artist.Name, artist.MbzArtistID) + if bio != "" && err == nil { + artist.Biography = bio + } + } + }() +} + +func (e *externalInfo2) callGetSimilar(ctx context.Context, allAgents []agents.Interface, artist *model.Artist, limit int, wg *sync.WaitGroup) { + wg.Add(1) + go func() { + defer wg.Done() + for _, a := range allAgents { + agent, ok := a.(agents.ArtistSimilarRetriever) + if !ok { + continue + } + similar, err := agent.GetSimilar(artist.Name, artist.MbzArtistID, limit) + if len(similar) == 0 || err != nil { + continue + } + sa, err := e.mapSimilarArtists(ctx, similar, true) + if err != nil { + continue + } + artist.SimilarArtists = sa + } + }() +} + +func (e *externalInfo2) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) { + var result model.Artists + var notPresent []string + + // First select artists that are present. + for _, s := range similar { + sa, err := e.findArtistByName(ctx, s.Name) + if err != nil { + notPresent = append(notPresent, s.Name) + continue + } + result = append(result, *sa) + } + + // Then fill up with non-present artists + if includeNotPresent { + for _, s := range notPresent { + sa := model.Artist{ID: unavailableArtistID, Name: s} + result = append(result, sa) + } + } + + return result, nil +} + +func (e *externalInfo2) findArtistByName(ctx context.Context, artistName string) (*model.Artist, error) { + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"name": artistName}, + Max: 1, + }) + if err != nil { + return nil, err + } + if len(artists) == 0 { + return nil, model.ErrNotFound + } + return &artists[0], nil +} + +func (e *externalInfo2) loadSimilar(ctx context.Context, artist *model.Artist, includeNotPresent bool) error { + var ids []string + for _, sa := range artist.SimilarArtists { + if sa.ID == unavailableArtistID { + continue + } + ids = append(ids, sa.ID) + } + + similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"id": ids}, + }) + if err != nil { + return err + } + + // Use a map and iterate through original array, to keep the same order + artistMap := make(map[string]model.Artist) + for _, sa := range similar { + artistMap[sa.ID] = sa + } + + var loaded model.Artists + for _, sa := range artist.SimilarArtists { + la, ok := artistMap[sa.ID] + if !ok { + if !includeNotPresent { + continue + } + la = sa + la.ID = unavailableArtistID + } + loaded = append(loaded, la) + } + artist.SimilarArtists = loaded + return nil +} diff --git a/core/wire_providers.go b/core/wire_providers.go index 1d3ebaa4f..c89b1ef1a 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -17,7 +17,7 @@ var Set = wire.NewSet( GetImageCache, NewArchiver, NewNowPlayingRepository, - NewExternalInfo, + NewExternalInfo2, NewCacheWarmer, NewPlayers, LastFMNewClient,