feat(agents): support multiple languages for Last.fm and Deezer metadata (#4952)

* feat(lastfm): support multiple languages for album and artist info retrieval

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(lastfm): improve content validation for album and artist descriptions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(lastfm): remove single language test and clarify languages field in configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(deezer): support multiple languages for artist bio retrieval

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(lastfm): rename ignoredBiographies to ignoredContent for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-01-29 13:05:51 -05:00
committed by GitHub
parent c9e58e3666
commit 7b523d6b61
22 changed files with 528 additions and 102 deletions
+4 -6
View File
@@ -29,14 +29,12 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
language string
jwt jwtToken
}
func newClient(hc httpDoer, language string) *client {
func newClient(hc httpDoer) *client {
return &client{
httpDoer: hc,
language: language,
}
}
@@ -129,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
@@ -160,10 +158,10 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language)
req.Header.Set("Accept-Language", lang)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
+1 -1
View File
@@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en")
client = newClient(httpClient)
ctx = context.Background()
})
+12 -19
View File
@@ -18,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en")
client = newClient(httpClient)
})
Describe("ArtistImages", func() {
@@ -78,40 +78,33 @@ var _ = Describe("client", func() {
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the configured language", func() {
client = newClient(httpClient, "fr")
// Mock JWT token for the new client instance with a valid JWT
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
It("uses the provided language", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
@@ -142,7 +135,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999)
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
@@ -164,7 +157,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(MatchError("deezer: biography not found"))
})
@@ -174,7 +167,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
@@ -187,7 +180,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
+14 -3
View File
@@ -26,15 +26,19 @@ const deezerArtistSearchLimit = 50
type deezerAgent struct {
dataStore model.DataStore
client *client
languages []string
}
func deezerConstructor(dataStore model.DataStore) agents.Interface {
agent := &deezerAgent{dataStore: dataStore}
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, conf.Server.Deezer.Language)
agent.client = newClient(cachedHttpClient)
return agent
}
@@ -149,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
return "", err
}
return s.client.getArtistBio(ctx, artist.ID)
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() {
+171
View File
@@ -0,0 +1,171 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("deezerAgent", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.Deezer.Enabled = true
})
Describe("deezerConstructor", func() {
It("uses configured languages", func() {
conf.Server.Deezer.Languages = []string{"pt", "en"}
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
// Mock search artist (returns Michael Jackson)
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
// Mock JWT token
testJWT := createTestJWT(5 * time.Minute)
httpClient.jwtResponse = &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
}
})
setupAgent := func(languages []string) {
conf.Server.Deezer.Languages = languages
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
languages: languages,
}
}
It("returns content in first language when available (1 bio API call)", func() {
setupAgent([]string{"fr", "en"})
// French biography available
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
Expect(httpClient.bioRequestCount).To(Equal(1))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
})
It("falls back to second language when first returns empty (2 bio API calls)", func() {
setupAgent([]string{"ja", "en"})
// Japanese returns empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(httpClient.bioRequestCount).To(Equal(2))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
setupAgent([]string{"ja", "xx"})
// Both languages return empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.bioRequestCount).To(Equal(2))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
type langAwareHttpClient struct {
searchResponse *http.Response
jwtResponse *http.Response
bioResponses map[string]*http.Response
bioRequests []*http.Request
bioRequestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
bioResponses: make(map[string]*http.Response),
bioRequests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
// Handle search artist request
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
if c.searchResponse != nil {
return c.searchResponse, nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
}, nil
}
// Handle JWT token request
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
if c.jwtResponse != nil {
return c.jwtResponse, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
}, nil
}
// Handle bio request (GraphQL API)
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
c.bioRequestCount++
c.bioRequests = append(c.bioRequests, req)
lang := req.Header.Get("Accept-Language")
if resp, ok := c.bioResponses[lang]; ok {
return resp, nil
}
// Return empty bio by default
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
}, nil
}
panic("URL not mocked: " + req.URL.String())
}
+58 -36
View File
@@ -26,8 +26,8 @@ const (
sessionKeyProperty = "LastFMSessionKey"
)
var ignoredBiographies = []string{
// Unknown Artist
var ignoredContent = []string{
// Empty Artist/Album
`<a href="https://www.last.fm/music/`,
}
@@ -36,7 +36,7 @@ type lastfmAgent struct {
sessionKeys *agents.SessionKeys
apiKey string
secret string
lang string
languages []string
client *client
httpClient httpDoer
getInfoMutex sync.Mutex
@@ -48,7 +48,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
}
l := &lastfmAgent{
ds: ds,
lang: conf.Server.LastFM.Language,
languages: conf.Server.LastFM.Languages,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
@@ -58,7 +58,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
l.client = newClient(l.apiKey, l.secret, chc)
return l
}
@@ -68,22 +68,47 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
if err != nil {
return nil, err
// isValidContent checks if content is non-empty and not in the ignored list
func isValidContent(content string) bool {
content = strings.TrimSpace(content)
if content == "" {
return false
}
for _, ign := range ignoredContent {
if strings.HasPrefix(content, ign) {
return false
}
}
return true
}
return &agents.AlbumInfo{
Name: a.Name,
MBID: a.MBID,
Description: a.Description.Summary,
URL: a.URL,
}, nil
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
var a *Album
var resp agents.AlbumInfo
for _, lang := range l.languages {
var err error
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
if err != nil {
return nil, err
}
resp.Name = a.Name
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(a.Description.Summary) {
resp.Description = strings.TrimSpace(a.Description.Summary)
return &resp, nil
}
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
}
// This condition should not be hit (languages default to ["en"]), but just in case
if a == nil {
return nil, agents.ErrNotFound
}
return &resp, nil
}
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
if err != nil {
return nil, err
}
@@ -118,7 +143,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name)
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
@@ -129,7 +154,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name)
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
@@ -140,20 +165,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
if a.Bio.Summary == "" {
return "", agents.ErrNotFound
}
for _, ign := range ignoredBiographies {
if strings.HasPrefix(a.Bio.Summary, ign) {
return "", nil
for _, lang := range l.languages {
a, err := l.callArtistGetInfo(ctx, name, lang)
if err != nil {
return "", err
}
if isValidContent(a.Bio.Summary) {
return strings.TrimSpace(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
}
return a.Bio.Summary, nil
return "", agents.ErrNotFound
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
@@ -219,7 +241,7 @@ var (
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
a, err := l.callArtistGetInfo(ctx, name)
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
@@ -259,14 +281,14 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "")
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
}
if err != nil {
@@ -280,11 +302,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name)
a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
+155 -10
View File
@@ -39,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
})
Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() {
It("uses configured api key and language", func() {
conf.Server.LastFM.Language = "pt"
It("uses configured api key and languages", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
Expect(agent.lang).To(Equal("pt"))
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
When("Agent is disabled", func() {
@@ -72,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -102,12 +102,129 @@ var _ = Describe("lastfmAgent", func() {
})
})
Describe("Language Fallback", func() {
Describe("GetArtistBiography", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("returns content in first language when available (1 API call)", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Portuguese biography available
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
Expect(httpClient.requestCount).To(Equal(1))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
})
It("falls back to second language when first returns empty (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return empty/ignored biography (using actual Last.fm response format)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// Second language also returns empty
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.requestCount).To(Equal(2))
})
})
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("falls back to second language when first returns empty description (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns album without wiki/description (actual Last.fm response)
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns album with description
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns album without description when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return album without description
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(BeEmpty())
Expect(httpClient.requestCount).To(Equal(2))
})
})
})
Describe("GetSimilarArtists", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -145,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -183,7 +300,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -233,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "en", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
@@ -407,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -477,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() {
apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", apiClient)
client := newClient("API_KEY", "SECRET", apiClient)
agent = lastFMConstructor(ds)
agent.client = client
agent.httpClient = httpClient
@@ -538,3 +655,31 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
type langAwareHttpClient struct {
responses map[string]http.Response
requests []*http.Request
requestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
responses: make(map[string]http.Response),
requests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
c.requestCount++
c.requests = append(c.requests, req)
lang := req.URL.Query().Get("lang")
if resp, ok := c.responses[lang]; ok {
return &resp, nil
}
// Return default empty response if no specific response is configured
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
}, nil
}
+1 -1
View File
@@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
r.client = newClient(r.apiKey, r.secret, "en", hc)
r.client = newClient(r.apiKey, r.secret, hc)
return r
}
+6 -7
View File
@@ -34,24 +34,23 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
return &client{apiKey, secret, lang, hc}
func newClient(apiKey string, secret string, hc httpDoer) *client {
return &client{apiKey, secret, hc}
}
type client struct {
apiKey string
secret string
lang string
hc httpDoer
}
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
params.Add("album", name)
params.Add("artist", artist)
params.Add("mbid", mbid)
params.Add("lang", c.lang)
params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil
}
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
params.Add("lang", c.lang)
params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
+8 -8
View File
@@ -22,7 +22,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", "pt", httpClient)
client = newClient("API_KEY", "SECRET", httpClient)
})
Describe("albumGetInfo", func() {
@@ -30,7 +30,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
@@ -42,7 +42,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
artist, err := client.artistGetInfo(context.Background(), "U2")
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
@@ -54,7 +54,7 @@ var _ = Describe("client", func() {
StatusCode: 500,
}
_, err := client.artistGetInfo(context.Background(), "U2")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("last.fm http status: (500)"))
})
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400,
}
_, err := client.artistGetInfo(context.Background(), "U2")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
})
@@ -74,14 +74,14 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
})
It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error")
_, err := client.artistGetInfo(context.Background(), "U2")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("generic error"))
})
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
})