mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(deezer): pick most-popular artist among same-name matches The Deezer agent searched with order=RANKING and always took the top-ranked result (artists[0]) as long as its name matched. Deezer's RANKING order isn't reliable for homonyms, so for names shared by several artists (e.g. "Queen") it locked onto a low-popularity artist whose Top Tracks are empty, leaving getTopSongs empty. Among the exact-name matches, select the one with the highest fan count instead. This resolves "Queen" to the real band (Deezer ID 412) and preserves the existing ErrNotFound behavior when nothing matches the name exactly. Fixes #5802 * fix(deezer): improve artist disambiguation by ranking exact-case names Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
232 lines
7.2 KiB
Go
232 lines
7.2 KiB
Go
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("searchArtist", func() {
|
|
var agent *deezerAgent
|
|
var httpClient *fakeHttpClient
|
|
|
|
BeforeEach(func() {
|
|
httpClient = &fakeHttpClient{}
|
|
agent = &deezerAgent{
|
|
dataStore: &tests.MockDataStore{},
|
|
client: newClient(httpClient),
|
|
}
|
|
})
|
|
|
|
It("picks the exact-name match with the most fans when several share the name", func() {
|
|
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
|
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
{"id":61045802,"name":"Queen","nb_fan":75},
|
|
{"id":141954732,"name":"Queen","nb_fan":397},
|
|
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
|
{"id":183179807,"name":"Queen","nb_fan":53},
|
|
{"id":412,"name":"Queen","nb_fan":12744378}
|
|
],"total":5}`)),
|
|
})
|
|
|
|
artist, err := agent.searchArtist(ctx, "Queen")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artist.ID).To(Equal(412))
|
|
})
|
|
|
|
It("matches the name case-insensitively", func() {
|
|
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
{"id":1,"name":"QUEEN","nb_fan":10},
|
|
{"id":2,"name":"queen","nb_fan":20}
|
|
],"total":2}`)),
|
|
})
|
|
|
|
artist, err := agent.searchArtist(ctx, "Queen")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artist.ID).To(Equal(2))
|
|
})
|
|
|
|
It("returns ErrNotFound when no result matches the name exactly", func() {
|
|
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
|
],"total":1}`)),
|
|
})
|
|
|
|
_, err := agent.searchArtist(ctx, "Queen")
|
|
|
|
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
})
|
|
})
|
|
|
|
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())
|
|
}
|