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:
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient(httpClient, "en")
|
||||
client = newClient(httpClient)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user