mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
205 lines
5.3 KiB
Go
205 lines
5.3 KiB
Go
package deezer
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
const deezerAgentName = "deezer"
|
|
const deezerApiPictureXlSize = 1000
|
|
const deezerApiPictureBigSize = 500
|
|
const deezerApiPictureMediumSize = 250
|
|
const deezerApiPictureSmallSize = 56
|
|
const deezerArtistSearchLimit = 50
|
|
|
|
type deezerAgent struct {
|
|
dataStore model.DataStore
|
|
client *client
|
|
languages []string
|
|
}
|
|
|
|
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
|
agent := &deezerAgent{
|
|
dataStore: dataStore,
|
|
languages: conf.Server.Deezer.Languages,
|
|
}
|
|
httpClient := &http.Client{
|
|
Timeout: consts.DefaultHttpClientTimeOut,
|
|
}
|
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
|
agent.client = newClient(cachedHttpClient)
|
|
return agent
|
|
}
|
|
|
|
func (s *deezerAgent) AgentName() string {
|
|
return deezerAgentName
|
|
}
|
|
|
|
func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
|
|
artist, err := s.searchArtist(ctx, name)
|
|
if err != nil {
|
|
if errors.Is(err, agents.ErrNotFound) {
|
|
log.Warn(ctx, "Artist not found in deezer", "artist", name)
|
|
} else {
|
|
log.Error(ctx, "Error calling deezer", "artist", name, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var res []agents.ExternalImage
|
|
possibleImages := []struct {
|
|
URL string
|
|
Size int
|
|
}{
|
|
{artist.PictureXl, deezerApiPictureXlSize},
|
|
{artist.PictureBig, deezerApiPictureBigSize},
|
|
{artist.PictureMedium, deezerApiPictureMediumSize},
|
|
{artist.PictureSmall, deezerApiPictureSmallSize},
|
|
}
|
|
for _, imgData := range possibleImages {
|
|
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
|
|
res = append(res, agents.ExternalImage{
|
|
URL: imgData.URL,
|
|
Size: imgData.Size,
|
|
})
|
|
}
|
|
}
|
|
if len(res) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
|
|
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
|
|
const deezerEmptyPicturePath = "/images/artist//"
|
|
|
|
func isPlaceholderPicture(url string) bool {
|
|
return strings.Contains(url, deezerEmptyPicturePath)
|
|
}
|
|
|
|
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
|
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
|
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
|
|
for i := range artists {
|
|
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
|
|
if i > 2 {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
|
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
|
rank := func(a Artist) int {
|
|
switch {
|
|
case a.Name == name:
|
|
return 2
|
|
case strings.EqualFold(a.Name, name):
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
slices.SortFunc(artists, func(a, b Artist) int {
|
|
return cmp.Or(
|
|
cmp.Compare(rank(b), rank(a)),
|
|
cmp.Compare(b.NbFan, a.NbFan),
|
|
cmp.Compare(a.ID, b.ID),
|
|
)
|
|
})
|
|
best := artists[0]
|
|
if !strings.EqualFold(best.Name, name) {
|
|
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
|
return new(best), nil
|
|
}
|
|
|
|
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
|
artist, err := s.searchArtist(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
related, err := s.client.getRelatedArtists(ctx, artist.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := slice.Map(related, func(r Artist) agents.Artist {
|
|
return agents.Artist{
|
|
Name: r.Name,
|
|
}
|
|
})
|
|
if len(res) > limit {
|
|
res = res[:limit]
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
|
|
artist, err := s.searchArtist(ctx, artistName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := slice.Map(tracks, func(r Track) agents.Song {
|
|
return agents.Song{
|
|
Name: r.Title,
|
|
Album: r.Album.Title,
|
|
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
|
}
|
|
})
|
|
return res, nil
|
|
}
|
|
|
|
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
|
|
artist, err := s.searchArtist(ctx, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, lang := range s.languages {
|
|
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
|
|
if err == nil && bio != "" {
|
|
return bio, nil
|
|
}
|
|
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
|
|
}
|
|
return "", agents.ErrNotFound
|
|
}
|
|
|
|
func init() {
|
|
conf.AddHook(func() {
|
|
if conf.Server.Deezer.Enabled {
|
|
agents.Register(deezerAgentName, deezerConstructor)
|
|
}
|
|
})
|
|
}
|