From 255ed1f8e2285c6dd1938c71225726b8e4765f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Nov 2025 15:09:24 -0500 Subject: [PATCH 1/4] feat(deezer): Add artist bio, top tracks, related artists and language support (#4720) * feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist Signed-off-by: Deluan * feat(deezer): add language support for Deezer API client Signed-off-by: Deluan * fix(deezer): Use GraphQL API for translated biographies The previous implementation scraped the __DZR_APP_STATE__ from HTML, which only contained English content. The actual biography displayed on Deezer's website comes from their GraphQL API at pipe.deezer.com, which properly respects the Accept-Language header and returns translated content. This change: - Switches from HTML scraping to the GraphQL API - Uses Accept-Language header instead of URL path for language - Updates tests to match the new implementation - Removes unused HTML fixture file Signed-off-by: Deluan * refactor(deezer): move JWT token handling to a separate file for better organization Signed-off-by: Deluan * feat(deezer): enhance JWT token handling with expiration validation Signed-off-by: Deluan * refactor(deezer): change log level for unknown agent warnings from Warn to Debug Signed-off-by: Deluan * fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 4 +- core/agents/agents.go | 2 +- core/agents/deezer/client.go | 141 ++++++++++- core/agents/deezer/client_auth.go | 101 ++++++++ core/agents/deezer/client_auth_test.go | 293 ++++++++++++++++++++++ core/agents/deezer/client_test.go | 135 +++++++++- core/agents/deezer/deezer.go | 53 +++- core/agents/deezer/responses.go | 35 +++ core/agents/deezer/responses_test.go | 31 +++ tests/fixtures/deezer.artist.bio.json | 9 + tests/fixtures/deezer.artist.related.json | 1 + tests/fixtures/deezer.artist.top.json | 1 + 12 files changed, 796 insertions(+), 10 deletions(-) create mode 100644 core/agents/deezer/client_auth.go create mode 100644 core/agents/deezer/client_auth_test.go create mode 100644 tests/fixtures/deezer.artist.bio.json create mode 100644 tests/fixtures/deezer.artist.related.json create mode 100644 tests/fixtures/deezer.artist.top.json diff --git a/conf/configuration.go b/conf/configuration.go index a9fee00e4..0ad81492a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -176,7 +176,8 @@ type spotifyOptions struct { } type deezerOptions struct { - Enabled bool + Enabled bool + Language string } type listenBrainzOptions struct { @@ -566,6 +567,7 @@ func setViperDefaults() { viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.secret", "") viper.SetDefault("deezer.enabled", true) + viper.SetDefault("deezer.language", "en") viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY") diff --git a/core/agents/agents.go b/core/agents/agents.go index 4ec324b71..cb10d2c4c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Warn("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name) } } return validAgents diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go index e75526d80..32d93bad6 100644 --- a/core/agents/deezer/client.go +++ b/core/agents/deezer/client.go @@ -1,6 +1,7 @@ package deezer import ( + bytes "bytes" "context" "encoding/json" "errors" @@ -9,11 +10,14 @@ import ( "net/http" "net/url" "strconv" + "strings" + "github.com/microcosm-cc/bluemonday" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" +const authBaseURL = "https://auth.deezer.com" var ( ErrNotFound = errors.New("deezer: not found") @@ -25,10 +29,15 @@ type httpDoer interface { type client struct { httpDoer httpDoer + language string + jwt jwtToken } -func newClient(hc httpDoer) *client { - return &client{hc} +func newClient(hc httpDoer, language string) *client { + return &client{ + httpDoer: hc, + language: language, + } } func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { @@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A return results.Data, nil } -func (c *client) makeRequest(req *http.Request, response interface{}) error { +func (c *client) makeRequest(req *http.Request, response any) error { log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL) resp, err := c.httpDoer.Do(req) if err != nil { @@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error { } return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } + +func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil) + if err != nil { + return nil, err + } + + var results RelatedArtists + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + return results.Data, nil +} + +func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) { + params := url.Values{} + params.Add("limit", strconv.Itoa(limit)) + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = params.Encode() + + var results TopTracks + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + return results.Data, nil +} + +const pipeAPIURL = "https://pipe.deezer.com/api" + +var strictPolicy = bluemonday.StrictPolicy() + +func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) { + jwt, err := c.getJWT(ctx) + if err != nil { + return "", fmt.Errorf("deezer: failed to get JWT: %w", err) + } + + query := map[string]any{ + "operationName": "ArtistBio", + "variables": map[string]any{ + "artistId": strconv.Itoa(artistID), + }, + "query": `query ArtistBio($artistId: String!) { + artist(artistId: $artistId) { + bio { + full + } + } + }`, + } + + body, err := json.Marshal(query) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", c.language) + req.Header.Set("Authorization", "Bearer "+jwt) + + log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language) + resp, err := c.httpDoer.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + type graphQLResponse struct { + Data struct { + Artist struct { + Bio struct { + Full string `json:"full"` + } `json:"bio"` + } `json:"artist"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } + } + + var result graphQLResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err) + } + + if len(result.Errors) > 0 { + var errs []error + for m := range result.Errors { + errs = append(errs, errors.New(result.Errors[m].Message)) + } + err := errors.Join(errs...) + return "", fmt.Errorf("deezer: GraphQL error: %w", err) + } + + if result.Data.Artist.Bio.Full == "" { + return "", errors.New("deezer: biography not found") + } + + return cleanBio(result.Data.Artist.Bio.Full), nil +} + +func cleanBio(bio string) string { + bio = strings.ReplaceAll(bio, "

", "\n") + return strictPolicy.Sanitize(bio) +} diff --git a/core/agents/deezer/client_auth.go b/core/agents/deezer/client_auth.go new file mode 100644 index 000000000..c88c2bcb6 --- /dev/null +++ b/core/agents/deezer/client_auth.go @@ -0,0 +1,101 @@ +package deezer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/navidrome/navidrome/log" +) + +type jwtToken struct { + token string + expiresAt time.Time + mu sync.RWMutex +} + +func (j *jwtToken) get() (string, bool) { + j.mu.RLock() + defer j.mu.RUnlock() + if time.Now().Before(j.expiresAt) { + return j.token, true + } + return "", false +} + +func (j *jwtToken) set(token string, expiresIn time.Duration) { + j.mu.Lock() + defer j.mu.Unlock() + j.token = token + j.expiresAt = time.Now().Add(expiresIn) +} + +func (c *client) getJWT(ctx context.Context) (string, error) { + // Check if we have a valid cached token + if token, valid := c.jwt.get(); valid { + return token, nil + } + + // Fetch a new anonymous token + req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpDoer.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + type authResponse struct { + JWT string `json:"jwt"` + } + + var result authResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("deezer: failed to parse auth response: %w", err) + } + + if result.JWT == "" { + return "", errors.New("deezer: no JWT token in response") + } + + // Parse JWT to get actual expiration time + token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err) + } + + // Calculate TTL with a 1-minute buffer for clock skew and network delays + expiresAt := token.Expiration() + if expiresAt.IsZero() { + return "", errors.New("deezer: JWT token has no expiration time") + } + + ttl := time.Until(expiresAt) - 1*time.Minute + if ttl <= 0 { + return "", errors.New("deezer: JWT token already expired or expires too soon") + } + + c.jwt.set(result.JWT, ttl) + log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl) + + return result.JWT, nil +} diff --git a/core/agents/deezer/client_auth_test.go b/core/agents/deezer/client_auth_test.go new file mode 100644 index 000000000..b0c2d195d --- /dev/null +++ b/core/agents/deezer/client_auth_test.go @@ -0,0 +1,293 @@ +package deezer + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("JWT Authentication", func() { + var httpClient *fakeHttpClient + var client *client + var ctx context.Context + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + client = newClient(httpClient, "en") + ctx = context.Background() + }) + + Describe("getJWT", func() { + Context("with a valid JWT response", func() { + It("successfully fetches and caches a JWT token", func() { + 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"}`, testJWT))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).To(Equal(testJWT)) + }) + + It("returns the cached token on subsequent calls", func() { + 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"}`, testJWT))), + }) + + // First call should fetch from API + token1, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token1).To(Equal(testJWT)) + Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous")) + + // Second call should return cached token without hitting API + httpClient.lastRequest = nil // Clear last request to verify no new request is made + token2, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token2).To(Equal(testJWT)) + Expect(httpClient.lastRequest).To(BeNil()) // No new request made + }) + + It("parses the JWT expiration time correctly", func() { + expectedExpiration := time.Now().Add(5 * time.Minute) + testToken, err := jwt.NewBuilder(). + Expiration(expectedExpiration). + Build() + Expect(err).To(BeNil()) + testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) + Expect(err).To(BeNil()) + + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).ToNot(BeEmpty()) + + // Verify the token is cached until close to expiration + // The cache should expire 1 minute before the JWT expires + expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute) + Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second)) + }) + }) + + Context("with JWT tokens that expire soon", func() { + It("rejects tokens that expire in less than 1 minute", func() { + // Create a token that expires in 30 seconds (less than 1-minute buffer) + testJWT := createTestJWT(30 * time.Second) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + + It("rejects already expired tokens", func() { + // Create a token that expired 1 minute ago + testJWT := createTestJWT(-1 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + + It("accepts tokens that expire in more than 1 minute", func() { + // Create a token that expires in 2 minutes (just over the 1-minute buffer) + testJWT := createTestJWT(2 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), + }) + + token, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token).ToNot(BeEmpty()) + }) + }) + + Context("with invalid responses", func() { + It("handles HTTP error responses", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get JWT token")) + }) + + It("handles malformed JSON responses", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse auth response")) + }) + + It("handles responses with empty JWT field", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("deezer: no JWT token in response")) + }) + + It("handles invalid JWT tokens", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)), + }) + + _, err := client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse JWT token")) + }) + + It("rejects JWT tokens without expiration", func() { + // Create a JWT without expiration claim + testToken, err := jwt.NewBuilder(). + Claim("custom", "value"). + Build() + Expect(err).To(BeNil()) + + // Verify token has no expiration + Expect(testToken.Expiration().IsZero()).To(BeTrue()) + + testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) + Expect(err).To(BeNil()) + + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), + }) + + _, err = client.getJWT(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time")) + }) + }) + + Context("token caching behavior", func() { + It("fetches a new token when the cached token expires", func() { + // First token expires in 5 minutes + firstJWT := 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"}`, firstJWT))), + }) + + token1, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token1).To(Equal(firstJWT)) + + // Manually expire the cached token + client.jwt.expiresAt = time.Now().Add(-1 * time.Second) + + // Second token with different expiration (10 minutes) + secondJWT := createTestJWT(10 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))), + }) + + token2, err := client.getJWT(ctx) + Expect(err).To(BeNil()) + Expect(token2).To(Equal(secondJWT)) + Expect(token2).ToNot(Equal(token1)) + }) + }) + }) + + Describe("jwtToken cache", func() { + var cache *jwtToken + + BeforeEach(func() { + cache = &jwtToken{} + }) + + It("returns false for expired tokens", func() { + cache.set("test-token", -1*time.Second) // Already expired + token, valid := cache.get() + Expect(valid).To(BeFalse()) + Expect(token).To(BeEmpty()) + }) + + It("returns true for valid tokens", func() { + cache.set("test-token", 4*time.Minute) + token, valid := cache.get() + Expect(valid).To(BeTrue()) + Expect(token).To(Equal("test-token")) + }) + + It("is thread-safe for concurrent access", func() { + wg := sync.WaitGroup{} + + // Writer goroutine + wg.Go(func() { + for i := 0; i < 100; i++ { + cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour) + time.Sleep(1 * time.Millisecond) + } + }) + + // Reader goroutine + wg.Go(func() { + for i := 0; i < 100; i++ { + cache.get() + time.Sleep(1 * time.Millisecond) + } + }) + + // Wait for both goroutines to complete + wg.Wait() + + // Verify final state is valid + token, valid := cache.get() + Expect(valid).To(BeTrue()) + Expect(token).To(HavePrefix("token-")) + }) + }) +}) + +// createTestJWT creates a valid JWT token for testing purposes +func createTestJWT(expiresIn time.Duration) string { + token, err := jwt.NewBuilder(). + Expiration(time.Now().Add(expiresIn)). + Build() + if err != nil { + panic(fmt.Sprintf("failed to create test JWT: %v", err)) + } + signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature()) + if err != nil { + panic(fmt.Sprintf("failed to sign test JWT: %v", err)) + } + return string(signed) +} diff --git a/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go index 5e47460d4..7e4f7a49f 100644 --- a/core/agents/deezer/client_test.go +++ b/core/agents/deezer/client_test.go @@ -2,10 +2,11 @@ package deezer import ( "bytes" - "context" + "fmt" "io" "net/http" "os" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -17,7 +18,7 @@ var _ = Describe("client", func() { BeforeEach(func() { httpClient = &fakeHttpClient{} - client = newClient(httpClient) + client = newClient(httpClient, "en") }) Describe("ArtistImages", func() { @@ -26,7 +27,7 @@ var _ = Describe("client", func() { Expect(err).To(BeNil()) httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200}) - artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) Expect(err).To(BeNil()) Expect(artists).To(HaveLen(17)) Expect(artists[0].Name).To(Equal("Michael Jackson")) @@ -39,10 +40,136 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), }) - _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) Expect(err).To(MatchError(ErrNotFound)) }) }) + + Describe("ArtistBio", func() { + BeforeEach(func() { + // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes + 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))), + }) + }) + + It("returns artist bio from a successful request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.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) + Expect(err).To(BeNil()) + Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) + Expect(bio).ToNot(ContainSubstring("

")) + Expect(bio).ToNot(ContainSubstring("

")) + }) + + 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") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + 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") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + // Verify that the Authorization header has the Bearer token format + authHeader := httpClient.lastRequest.Header.Get("Authorization") + Expect(authHeader).To(HavePrefix("Bearer ")) + Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars + }) + + It("handles GraphQL errors", func() { + errorResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + }, + "errors": [ + { + "message": "Artist not found" + }, + { + "message": "Invalid artist ID" + } + ] + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(errorResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 999) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("GraphQL error")) + Expect(err.Error()).To(ContainSubstring("Artist not found")) + Expect(err.Error()).To(ContainSubstring("Invalid artist ID")) + }) + + It("handles empty biography", func() { + emptyBioResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + } + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(MatchError("deezer: biography not found")) + }) + + It("handles JWT token fetch failure", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get JWT")) + }) + + It("handles JWT token that expires too soon", func() { + // Create a JWT that expires in 30 seconds (less than the 1-minute buffer) + expiredJWT := createTestJWT(30 * time.Second) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + }) }) type fakeHttpClient struct { diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go index 8cabfbcfb..8f3e505ec 100644 --- a/core/agents/deezer/deezer.go +++ b/core/agents/deezer/deezer.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" ) const deezerAgentName = "deezer" @@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { Timeout: consts.DefaultHttpClientTimeOut, } cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) - agent.client = newClient(cachedHttpClient) + agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) return agent } @@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e return &artists[0], err } +func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return nil, err + } + + related, err := s.client.getRelatedArtists(ctx, artist.ID) + if err != nil { + return nil, err + } + + res := slice.Map(related, func(r Artist) agents.Artist { + return agents.Artist{ + Name: r.Name, + } + }) + if len(res) > limit { + res = res[:limit] + } + return res, nil +} + +func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) { + artist, err := s.searchArtist(ctx, artistName) + if err != nil { + return nil, err + } + + tracks, err := s.client.getTopTracks(ctx, artist.ID, count) + if err != nil { + return nil, err + } + + res := slice.Map(tracks, func(r Track) agents.Song { + return agents.Song{ + Name: r.Title, + } + }) + return res, nil +} + +func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return "", err + } + + return s.client.getArtistBio(ctx, artist.ID) +} + func init() { conf.AddHook(func() { if conf.Server.Deezer.Enabled { diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go index 112fe28ec..266c44c62 100644 --- a/core/agents/deezer/responses.go +++ b/core/agents/deezer/responses.go @@ -29,3 +29,38 @@ type Error struct { Code int `json:"code"` } `json:"error"` } + +type RelatedArtists struct { + Data []Artist `json:"data"` + Total int `json:"total"` +} + +type TopTracks struct { + Data []Track `json:"data"` + Total int `json:"total"` + Next string `json:"next"` +} + +type Track struct { + ID int `json:"id"` + Title string `json:"title"` + Link string `json:"link"` + Duration int `json:"duration"` + Rank int `json:"rank"` + Preview string `json:"preview"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + Contributors []Artist `json:"contributors"` +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverSmall string `json:"cover_small"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXl string `json:"cover_xl"` + Tracklist string `json:"tracklist"` + Type string `json:"type"` +} diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go index 95a7f43f4..a9de5c5fb 100644 --- a/core/agents/deezer/responses_test.go +++ b/core/agents/deezer/responses_test.go @@ -35,4 +35,35 @@ var _ = Describe("Responses", func() { Expect(errorResp.Error.Message).To(Equal("Missing parameters: q")) }) }) + + Describe("Related Artists", func() { + It("parses the related artists response correctly", func() { + var resp RelatedArtists + body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(20)) + justice := resp.Data[0] + Expect(justice.Name).To(Equal("Justice")) + Expect(justice.ID).To(Equal(6404)) + }) + }) + + Describe("Top Tracks", func() { + It("parses the top tracks response correctly", func() { + var resp TopTracks + body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(5)) + track := resp.Data[0] + Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) + Expect(track.ID).To(Equal(67238732)) + Expect(track.Album.Title).To(Equal("Random Access Memories")) + }) + }) }) diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json new file mode 100644 index 000000000..80e439bae --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.json @@ -0,0 +1,9 @@ +{ + "data": { + "artist": { + "bio": { + "full": "

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.

" + } + } + } +} diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json new file mode 100644 index 000000000..2a55b303e --- /dev/null +++ b/tests/fixtures/deezer.artist.related.json @@ -0,0 +1 @@ +{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20} \ No newline at end of file diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json new file mode 100644 index 000000000..e3f22a1aa --- /dev/null +++ b/tests/fixtures/deezer.artist.top.json @@ -0,0 +1 @@ +{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"} \ No newline at end of file From 67c4e249570c1928f3559a694427a6ce34adda67 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 21 Nov 2025 15:26:30 -0500 Subject: [PATCH 2/4] fix(scanner): defer artwork PreCache calls until after transaction commits The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation. Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images. The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning. Signed-off-by: Deluan --- scanner/phase_1_folders.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 2f6b62b2d..329029951 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) + // Collect artwork IDs to pre-cache after the transaction commits + var artworkIDs []model.ArtworkID + err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder folderRepo := tx.Folder(p.ctx) @@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - entry.job.cw.PreCache(entry.artists[i].CoverArtID()) + artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) } } @@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - entry.job.cw.PreCache(entry.albums[i].CoverArtID()) + artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) } } @@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) if err != nil { log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } + + // Pre-cache artwork after the transaction commits successfully + if err == nil { + for _, artID := range artworkIDs { + entry.job.cw.PreCache(artID) + } + } + return entry, err } From f6b2ab57262c0c6c411a1002be8cc31c75f270b6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 21 Nov 2025 22:23:38 -0500 Subject: [PATCH 3/4] feat(ui): add loading state to artist action buttons for improved user experience Signed-off-by: Deluan --- ui/src/artist/ArtistActions.jsx | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index c33ee892b..8eebe6499 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import { useDispatch } from 'react-redux' -import { useMediaQuery } from '@material-ui/core' +import { useMediaQuery, CircularProgress } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { Button, @@ -45,6 +45,12 @@ const useStyles = makeStyles((theme) => ({ }, })) +const LoadingButton = ({ loading, icon, ...rest }) => ( + +) + const ArtistActions = ({ className, record, ...rest }) => { const dispatch = useDispatch() const translate = useTranslate() @@ -52,34 +58,45 @@ const ArtistActions = ({ className, record, ...rest }) => { const notify = useNotify() const classes = useStyles() const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const [loadingAction, setLoadingAction] = React.useState(null) + const isLoading = !!loadingAction const handlePlay = React.useCallback(async () => { + setLoadingAction('play') try { await playTopSongs(dispatch, notify, record.name) } catch (e) { // eslint-disable-next-line no-console console.error('Error fetching top songs for artist:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dispatch, notify, record]) const handleShuffle = React.useCallback(async () => { + setLoadingAction('shuffle') try { await playShuffle(dataProvider, dispatch, record.id) } catch (e) { // eslint-disable-next-line no-console console.error('Error fetching songs for shuffle:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dataProvider, dispatch, record, notify]) const handleRadio = React.useCallback(async () => { + setLoadingAction('radio') try { await playSimilar(dispatch, notify, record.id) } catch (e) { // eslint-disable-next-line no-console console.error('Error starting radio for artist:', e) notify('ra.page.error', 'warning') + } finally { + setLoadingAction(null) } }, [dispatch, notify, record]) @@ -88,30 +105,33 @@ const ArtistActions = ({ className, record, ...rest }) => { className={`${className} ${classes.toolbar}`} {...sanitizeListRestProps(rest)} > - - - + disabled={isLoading} + loading={loadingAction === 'radio'} + icon={} + /> ) } From 2451e9e7aeca3040ce463c86351352b61121ca6f Mon Sep 17 00:00:00 2001 From: Stephan Wahlen <44159957+metalheim@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:23:02 +0100 Subject: [PATCH 4/4] feat(ui): add AMusic (Apple Music inspired) theme (#4723) * first show at AMuisc Theme * prettier * fix Duplicate key 'MuiButton' * fix file name * Update amusic.js * Add styles for NDAlbumGridView in amusic.js * Fix MuiToolbar background property in amusic.js * Fix syntax error in amusic.js background property * run prettier * fix banded table styling and more * more styling to player - fix some appearances of green in queue - match queue styling to rest of theme - round albumart in player and prevent rotation * fix queue panel background and border to make it stand out more against the background * fix stray comma and lint+prettier * queue hover still green and player preview image not rounded properly * Update amusic.css.js * more mobile color fixes * artist page * prettier * rounded art in albumgridview * small tweaks to colors and radiuses * artist and album heading * external links colors * unify font colors + albumgrid corner radius * get rid of queue hover green * unify colors in player same red shades as primary * mobile player floating panel background shade of green * unify border colors and attempt to get album cover corner radius working * final touches * Update amusic.css.js * fix invisible button color fir muibutton Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix css syntax on player queue color overrides Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * remove unused MuiTableHead * sort theme list in index.js alphabetically * remove unused properties * Revert "fix css syntax on player queue color overrides" This reverts commit 503bba321d958aed5251667c58214822ceb70f59. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ui/src/themes/amusic.css.js | 89 ++++++++++++++++ ui/src/themes/amusic.js | 197 ++++++++++++++++++++++++++++++++++++ ui/src/themes/index.js | 2 + 3 files changed, 288 insertions(+) create mode 100644 ui/src/themes/amusic.css.js create mode 100644 ui/src/themes/amusic.js diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js new file mode 100644 index 000000000..05709dc1e --- /dev/null +++ b/ui/src/themes/amusic.css.js @@ -0,0 +1,89 @@ +const stylesheet = ` +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #D60017 +} +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ff4e6b +} +.react-jinke-music-player-main ::-webkit-scrollbar-thumb, +.react-jinke-music-player-mobile-progress .rc-slider-handle, +.react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ff4e6b +} +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ff4e6b +} +.audio-lists-panel-content .audio-item.playing, +.react-jinke-music-player-main .audio-item.playing svg, +.react-jinke-music-player-main .group player-delete { + color: #ff4e6b +} +.audio-lists-panel-content .audio-item:hover, +.audio-lists-panel-content .audio-item:hover svg +.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{ + color: #D60017 +} +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ff4e6b !important +} +.react-jinke-music-player-main .lyric-btn, +.react-jinke-music-player-main .lyric-btn-active svg{ + color: #ff4e6b !important +} +.react-jinke-music-player-main .lyric-btn-active { + color: #D60017 !important +} +.react-jinke-music-player-main .loading svg { + color: #ff4e6b !important +} +.react-jinke-music-player .music-player-controller .music-player-controller-setting{ + background: #ff4e6b4d +} +.react-jinke-music-player-main .music-player-lyric{ + color: #ff4e6b !important; + text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000 +} +.react-jinke-music-player-main .music-player-panel, +.react-jinke-music-player-mobile, +.ril__outer{ + background-color: #1f1f1f; + border: 1px solid #fff1; +} +.ril__toolbar{ + background-color: #1d1d1d +} +.ril__toolbarItem{ + font-size: 100%; + color: #eee +} +.audio-lists-panel{ + background-color: #1f1f1f; + border: 1px solid #fff1; + border-radius: 6px 6px 0 0; +} +.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate, +.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover, +.react-jinke-music-player-mobile-cover { + border-radius: 6px !important; + animation-duration: 0s !important +} +.react-jinke-music-player-main .music-player-panel .panel-content .img-content{ + width: 60px; + height: 60px +} +.react-jinke-music-player-main .songTitle{ + color: #eee +} +.react-jinke-music-player .music-player-controller{ + color: #ff4e6b +} +.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){ + background: unset +} +.lastfm-icon, +.musicbrainz-icon{ + color: #eee +} +` +export default stylesheet diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js new file mode 100644 index 000000000..598b7b7fa --- /dev/null +++ b/ui/src/themes/amusic.js @@ -0,0 +1,197 @@ +import stylesheet from './amusic.css.js' + +export default { + themeName: 'AMusic', + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif', + h6: { + fontSize: '1rem', // AppBar title + }, + h5: { + fontSize: '2em', + fontWeight: '600', + }, + }, + palette: { + primary: { + main: '#ff4e6b', + }, + secondary: { + main: '#D60017', + contrastText: '#eee', + }, + background: { + default: '#1a1a1a', + paper: '#1a1a1a', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: '#1d1d1d !important', + boxShadow: 'none', + borderBottom: '1px solid #fff1', + }, + colorSecondary: { + color: '#eee', + }, + }, + MuiDrawer: { + root: { + background: '#1d1d1d', + borderRight: '1px solid #fff1', + }, + }, + MuiToolbar: { + root: { + background: 'transparent !important', + }, + }, + MuiCardMedia: { + img: { + borderRadius: '10px', + boxShadow: '5px 5px 20px #111', + }, + }, + MuiButton: { + root: { + background: '#D60017', + color: '#fff', + borderRadius: '6px', + paddingRight: '0.5rem', + paddingLeft: '0.5rem', + marginLeft: '0.5rem', + marginBottom: '0.5rem', + textTransform: 'capitalize', + fontWeight: 600, + }, + textPrimary: { + color: '#eee', + }, + textSecondary: { + color: '#eee', + backgroundColor: '#ff4e6b', + }, + textSizeSmall: { + fontSize: '0.8rem', + paddingRight: '0.5rem', + paddingLeft: '0.5rem', + }, + label: { + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + }, + MuiListItemIcon: { + root: { + color: '#ff4e6b', + }, + }, + MuiChip: { + root: { + borderRadius: '6px', + }, + }, + MuiIconButton: { + root: { + color: '#ff4e6b', + }, + }, + MuiTableBody: { + root: { + '&>tr:nth-child(odd)': { + background: 'rgba(255, 255, 255, 0.025)', + }, + }, + }, + MuiTableRow: { + root: { + background: 'transparent', + }, + }, + MuiTableCell: { + root: { + borderBottom: '0 none !important', + padding: '10px !important', + color: '#b3b3b3 !important', + }, + head: { + color: '#b3b3b3 !important', + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + borderRadius: '10px', + color: '#eee', + }, + }, + NDAlbumGridView: { + albumName: { + color: '#eee', + }, + albumSubtitle: { + color: '#ccc', + }, + albumPlayButton: { + color: '#ff4e6b !important', + }, + albumArtistName: { + color: '#ff4e6b !important', + }, + cover: { + borderRadius: '10px !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#D60017', + }, + welcome: { + color: '#eee', + }, + card: { + minWidth: 300, + backgroundColor: '#1d1d1d', + }, + }, + MuiPaper: { + elevation1: { + boxShadow: 'none', + }, + root: { + color: '#eee', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: '#1a1a1a', + }, + artistName: { + fontWeight: '600', + fontSize: '2em', + }, + }, + NDDesktopArtistDetails: { + artistName: { + fontWeight: '600', + fontSize: '2em', + }, + artistDetail: { + padding: 'unset', + paddingBottom: '1rem', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 0234d416b..ea4a2472a 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -10,6 +10,7 @@ import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' import NuclearTheme from './nuclear' +import AmusicTheme from './amusic' export default { // Classic default themes @@ -17,6 +18,7 @@ export default { DarkTheme, // New themes should be added here, in alphabetic order + AmusicTheme, CatppuccinMacchiatoTheme, ElectricPurpleTheme, ExtraDarkTheme,