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 { type client struct {
httpDoer httpDoer httpDoer httpDoer
language string
jwt jwtToken jwt jwtToken
} }
func newClient(hc httpDoer, language string) *client { func newClient(hc httpDoer) *client {
return &client{ return &client{
httpDoer: hc, httpDoer: hc,
language: language,
} }
} }
@@ -129,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy() 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) jwt, err := c.getJWT(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err) 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("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language) req.Header.Set("Accept-Language", lang)
req.Header.Set("Authorization", "Bearer "+jwt) 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) resp, err := c.httpDoer.Do(req)
if err != nil { if err != nil {
return "", err return "", err
+1 -1
View File
@@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &fakeHttpClient{} httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en") client = newClient(httpClient)
ctx = context.Background() ctx = context.Background()
}) })
+12 -19
View File
@@ -18,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &fakeHttpClient{} httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en") client = newClient(httpClient)
}) })
Describe("ArtistImages", func() { Describe("ArtistImages", func() {
@@ -78,40 +78,33 @@ var _ = Describe("client", func() {
}) })
It("returns artist bio from a successful request", 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()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) 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(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>")) Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>")) Expect(bio).ToNot(ContainSubstring("</p>"))
}) })
It("uses the configured language", func() { It("uses the provided language", func() {
client = newClient(httpClient, "fr") f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
// 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")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) 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(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
}) })
It("includes the JWT token in the request", func() { 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()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) 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()) Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format // Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization") authHeader := httpClient.lastRequest.Header.Get("Authorization")
@@ -142,7 +135,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(errorResponse)), 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).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error")) Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found")) Expect(err.Error()).To(ContainSubstring("Artist not found"))
@@ -164,7 +157,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), 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")) 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"}`)), 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).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT")) 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))), 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).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) 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 { type deezerAgent struct {
dataStore model.DataStore dataStore model.DataStore
client *client client *client
languages []string
} }
func deezerConstructor(dataStore model.DataStore) agents.Interface { func deezerConstructor(dataStore model.DataStore) agents.Interface {
agent := &deezerAgent{dataStore: dataStore} agent := &deezerAgent{
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := &http.Client{ httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut, Timeout: consts.DefaultHttpClientTimeOut,
} }
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) agent.client = newClient(cachedHttpClient)
return agent return agent
} }
@@ -149,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
return "", err 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() { 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" sessionKeyProperty = "LastFMSessionKey"
) )
var ignoredBiographies = []string{ var ignoredContent = []string{
// Unknown Artist // Empty Artist/Album
`<a href="https://www.last.fm/music/`, `<a href="https://www.last.fm/music/`,
} }
@@ -36,7 +36,7 @@ type lastfmAgent struct {
sessionKeys *agents.SessionKeys sessionKeys *agents.SessionKeys
apiKey string apiKey string
secret string secret string
lang string languages []string
client *client client *client
httpClient httpDoer httpClient httpDoer
getInfoMutex sync.Mutex getInfoMutex sync.Mutex
@@ -48,7 +48,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
} }
l := &lastfmAgent{ l := &lastfmAgent{
ds: ds, ds: ds,
lang: conf.Server.LastFM.Language, languages: conf.Server.LastFM.Languages,
apiKey: conf.Server.LastFM.ApiKey, apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret, secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
@@ -58,7 +58,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
} }
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc) l.client = newClient(l.apiKey, l.secret, chc)
return l return l
} }
@@ -68,22 +68,47 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`) var imageRegex = regexp.MustCompile(`u\/(\d+)`)
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { // isValidContent checks if content is non-empty and not in the ignored list
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) func isValidContent(content string) bool {
if err != nil { content = strings.TrimSpace(content)
return nil, err if content == "" {
return false
} }
for _, ign := range ignoredContent {
if strings.HasPrefix(content, ign) {
return false
}
}
return true
}
return &agents.AlbumInfo{ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
Name: a.Name, var a *Album
MBID: a.MBID, var resp agents.AlbumInfo
Description: a.Description.Summary, for _, lang := range l.languages {
URL: a.URL, var err error
}, nil 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return "", err 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) { 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 { if err != nil {
return "", err 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) { func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name) for _, lang := range l.languages {
if err != nil { a, err := l.callArtistGetInfo(ctx, name, lang)
return "", err 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
} }
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) { 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) { func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name) 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 { if err != nil {
return nil, fmt.Errorf("get artist info: %w", err) 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 return res, nil
} }
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid) a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
var lfErr *lastFMError var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr) isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) { if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) 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 { if err != nil {
@@ -280,11 +302,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil 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() l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock() defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name) a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil { if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err return nil, err
+155 -10
View File
@@ -39,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
}) })
Describe("lastFMConstructor", func() { Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() { When("Agent is properly configured", func() {
It("uses configured api key and language", func() { It("uses configured api key and languages", func() {
conf.Server.LastFM.Language = "pt" conf.Server.LastFM.Languages = []string{"pt", "en"}
agent := lastFMConstructor(ds) agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123")) Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret")) 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() { When("Agent is disabled", func() {
@@ -72,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client 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() { Describe("GetSimilarArtists", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -145,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -183,7 +300,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -233,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "en", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
track = &model.MediaFile{ track = &model.MediaFile{
@@ -407,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -477,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
apiClient = &tests.FakeHttpClient{} apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", apiClient) client := newClient("API_KEY", "SECRET", apiClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
agent.httpClient = httpClient 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{ hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut, Timeout: consts.DefaultHttpClientTimeOut,
} }
r.client = newClient(r.apiKey, r.secret, "en", hc) r.client = newClient(r.apiKey, r.secret, hc)
return r return r
} }
+6 -7
View File
@@ -34,24 +34,23 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
} }
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { func newClient(apiKey string, secret string, hc httpDoer) *client {
return &client{apiKey, secret, lang, hc} return &client{apiKey, secret, hc}
} }
type client struct { type client struct {
apiKey string apiKey string
secret string secret string
lang string
hc httpDoer 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 := url.Values{}
params.Add("method", "album.getInfo") params.Add("method", "album.getInfo")
params.Add("album", name) params.Add("album", name)
params.Add("artist", artist) params.Add("artist", artist)
params.Add("mbid", mbid) params.Add("mbid", mbid)
params.Add("lang", c.lang) params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false) response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil 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 := url.Values{}
params.Add("method", "artist.getInfo") params.Add("method", "artist.getInfo")
params.Add("artist", name) params.Add("artist", name)
params.Add("lang", c.lang) params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false) response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil { if err != nil {
return nil, err return nil, err
+8 -8
View File
@@ -22,7 +22,7 @@ var _ = Describe("client", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", "pt", httpClient) client = newClient("API_KEY", "SECRET", httpClient)
}) })
Describe("albumGetInfo", func() { Describe("albumGetInfo", func() {
@@ -30,7 +30,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} 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(err).To(BeNil())
Expect(album.Name).To(Equal("Believe")) 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")) 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") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} 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(err).To(BeNil())
Expect(artist.Name).To(Equal("U2")) 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")) 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, StatusCode: 500,
} }
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("last.fm http status: (500)")) Expect(err).To(MatchError("last.fm http status: (500)"))
}) })
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400, 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"})) 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, 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"})) Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
}) })
It("fails if HttpClient.Do() returns error", func() { It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error") 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")) Expect(err).To(MatchError("generic error"))
}) })
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200, 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")) Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
}) })
+30 -2
View File
@@ -175,6 +175,9 @@ type lastfmOptions struct {
Secret string Secret string
Language string Language string
ScrobbleFirstArtistOnly bool ScrobbleFirstArtistOnly bool
// Computed values
Languages []string // Computed from Language, split by comma
} }
type spotifyOptions struct { type spotifyOptions struct {
@@ -185,6 +188,9 @@ type spotifyOptions struct {
type deezerOptions struct { type deezerOptions struct {
Enabled bool Enabled bool
Language string Language string
// Computed values
Languages []string // Computed from Language, split by comma
} }
type listenBrainzOptions struct { type listenBrainzOptions struct {
@@ -374,6 +380,12 @@ func Load(noConfigDump bool) {
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID) Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID) Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
logDeprecatedOptions("Scanner.GenreSeparators", "") logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "") logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
@@ -462,6 +474,22 @@ func validatePlaylistsPath() error {
return nil return nil
} }
// parseLanguages parses a comma-separated language string into a slice.
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
func parseLanguages(lang string) []string {
var languages []string
for _, l := range strings.Split(lang, ",") {
l = strings.TrimSpace(l)
if l != "" {
languages = append(languages, l)
}
}
if len(languages) == 0 {
return []string{consts.DefaultInfoLanguage}
}
return languages
}
func validatePurgeMissingOption() error { func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := false valid := false
@@ -618,14 +646,14 @@ func setViperDefaults() {
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic") viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer") viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en") viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "") viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", "en") viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("enablescrobblehistory", true)
+26
View File
@@ -26,6 +26,32 @@ var _ = Describe("Configuration", func() {
conf.ResetConf() conf.ResetConf()
}) })
Describe("ParseLanguages", func() {
It("parses single language", func() {
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
})
It("parses multiple comma-separated languages", func() {
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
})
It("trims whitespace from languages", func() {
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
})
It("returns default 'en' when empty", func() {
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
})
It("returns default 'en' when only whitespace", func() {
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
})
It("handles multiple languages with various spacing", func() {
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
})
})
DescribeTable("should load configuration from", DescribeTable("should load configuration from",
func(format string) { func(format string) {
filename := filepath.Join("testdata", "cfg."+format) filename := filepath.Join("testdata", "cfg."+format)
+2
View File
@@ -5,3 +5,5 @@ func ResetConf() {
} }
var SetViperDefaults = setViperDefaults var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
+2
View File
@@ -56,6 +56,8 @@ const (
ServerReadHeaderTimeout = 3 * time.Second ServerReadHeaderTimeout = 3 * time.Second
DefaultInfoLanguage = "en"
ArtistInfoTimeToLive = 24 * time.Hour ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute UpdateLastAccessFrequency = time.Minute
+10
View File
@@ -0,0 +1,10 @@
{
"data": {
"artist": {
"bio": null
}
},
"extensions": {
"queryCost": 3
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"data": {
"artist": {
"bio": {
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act. First major single <em>\"Da Funk\"</em> was accompanied by a Spike Jonze-directed video and more success followed with global dance floor anthem <em>\"Around the World,\" \"One More Time,\"</em> and <em>\"Harder, Faster, Better, Stronger\"</em> - which was sampled by Kanye West for his hit <em>\"Stronger.\"</em> Albums <em>Homework</em> (1997), <em>Discovery</em> (2001) and <em>Human After All</em> (2005) all made the UK Top 10 establishing a style of simple, Chicago house-inspired grooves exploding into a robotic, rave sound.</p>"
}
}
},
"extensions": {
"queryCost": 3
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"data": {
"artist": {
"bio": {
"full": "Guy-Manuel de Homem Christo et Thomas Bangalter se rencontrent en 1987 au lycée Carnot de Paris. Partageant une même passion pour la musique, les deux amis fondent en 1992 Darlin', un groupe de rock influencé par les Stooges et MC5, dont la production sera taxée par un critique de la presse anglaise de «daft punk» (« punk idiot »). <br />\n<br />\nDécouragés face à l'apathie du milieu rock, ils décident un peu plus tard de se lancer à corps perdus dans le courant Techno alors en pleine explosion. Arrive alors la découverte de la House, des clubs et des raves, dont une en particulier qui déterminera leur avenir : en 1993 est organisé à EuroDisney une rave où notre duo rencontre les dirigeants du label techno écossais Soma."
}
}
},
"extensions": {
"queryCost": 3
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"data": {
"artist": {
"bio": {
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.</p>"
}
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long