🏡 index : quine-global/quintodrome.git

package deezer

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"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/httpclient"
	"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 := httpclient.New(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 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)
		}
	})
}