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 <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-11-20 19:36:02 -05:00
parent cfd72c3c78
commit 1598f4a0a0
4 changed files with 239 additions and 12039 deletions

View File

@ -1,6 +1,7 @@
package deezer
import (
bytes "bytes"
"context"
"encoding/json"
"errors"
@ -8,15 +9,17 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"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")
@ -26,13 +29,39 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
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)
}
type client struct {
httpDoer httpDoer
language string
jwt jwtToken
}
func newClient(hc httpDoer, language string) *client {
return &client{hc, language}
return &client{
httpDoer: hc,
language: language,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
@ -57,7 +86,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 {
@ -119,17 +148,97 @@ func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]T
return results.Data, nil
}
var dzrAppStateRegex = regexp.MustCompile(`window\.__DZR_APP_STATE__\s*=\s*({.+?})\s*</script>`)
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
u := fmt.Sprintf("https://www.deezer.com/%s/artist/%d/biography", c.language, artistID)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
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
}
log.Trace(ctx, "Fetching Deezer artist biography", "url", u)
type authResponse struct {
JWT string `json:"jwt"`
RefreshToken string `json:"refresh_token"`
}
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")
}
// Cache the token for 50 minutes (tokens expire in 1 hour).
// The 10-minute buffer helps handle clock skew, network delays, or timing issues,
// ensuring we refresh the token before it actually expires.
// Note: c.jwt is assumed to be thread-safe.
// Cache the token for 50 minutes (tokens expire in 1 hour)
c.jwt.set(result.JWT, 50*time.Minute)
log.Trace(ctx, "Fetched new Deezer JWT token")
return result.JWT, nil
}
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
@ -140,36 +249,43 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error)
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
matches := dzrAppStateRegex.FindSubmatch(body)
if len(matches) < 2 {
return "", errors.New("deezer: could not find __DZR_APP_STATE__")
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"`
}
}
type appState struct {
Bio struct {
Bio string `json:"BIO"`
Resume string `json:"RESUME"`
} `json:"BIO"`
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
var state appState
if err := json.Unmarshal(matches[1], &state); err != nil {
return "", fmt.Errorf("deezer: failed to parse __DZR_APP_STATE__: %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)
}
var bio string
if state.Bio.Bio != "" {
bio = state.Bio.Bio
} else {
bio = state.Bio.Resume
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(bio), nil
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {

View File

@ -44,10 +44,18 @@ var _ = Describe("client", func() {
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"test-jwt-token","refresh_token":""}`)),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.html")
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://www.deezer.com/en/artist/27/biography", 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)
Expect(err).To(BeNil())
@ -58,13 +66,88 @@ var _ = Describe("client", func() {
It("uses the configured language", func() {
client = newClient(httpClient, "fr")
f, err := os.Open("tests/fixtures/deezer.artist.bio.html")
// Mock JWT token for the new client instance
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"test-jwt-token","refresh_token":""}`)),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://www.deezer.com/fr/artist/27/biography", 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)
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.URL.String()).To(Equal("https://www.deezer.com/fr/artist/27/biography"))
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())
Expect(httpClient.lastRequest.Header.Get("Authorization")).To(Equal("Bearer test-jwt-token"))
})
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"))
})
})
})

File diff suppressed because one or more lines are too long

9
tests/fixtures/deezer.artist.bio.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"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>"
}
}
}
}