navidrome/adapters/deezer/client.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

239 lines
5.6 KiB
Go
Raw Permalink Normal View History

package deezer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
2025-11-21 15:09:24 -05:00
"strings"
2025-11-21 15:09:24 -05:00
"github.com/microcosm-cc/bluemonday"
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
2025-11-21 15:09:24 -05:00
const authBaseURL = "https://auth.deezer.com"
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
// and no rate-limit headers, so the body code is the only signal.
const errCodeQuota = 4
type deezerError struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
}
func (e *deezerError) Error() string {
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
type client struct {
httpDoer httpDoer
2025-11-21 15:09:24 -05:00
jwt jwtToken
}
func newClient(hc httpDoer) *client {
2025-11-21 15:09:24 -05:00
return &client{
httpDoer: hc,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("order", "RANKING")
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results SearchArtistResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Data) == 0 {
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
return nil, agents.ErrNotFound
}
return results.Data, nil
}
2025-11-21 15:09:24 -05:00
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 {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
// Checked before the status: a throttled request still answers 200, and decoding its body
// into a result type yields an empty one, which reads as "nothing found".
if err := parseBodyError(data); err != nil {
return err
}
if resp.StatusCode != 200 {
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
}
return json.Unmarshal(data, response)
}
fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes.
2026-09-01 17:17:52 -04:00
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
func parseBodyError(data []byte) error {
var body errorResponse
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
_ = json.Unmarshal(data, &body)
switch {
case body.Error == nil:
return nil
case body.Error.Code == errCodeQuota:
return errors.Join(body.Error, agents.ErrRetryLater)
default:
return body.Error
}
}
2025-11-21 15:09:24 -05:00
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, lang string) (string, error) {
2025-11-21 15:09:24 -05:00
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", lang)
2025-11-21 15:09:24 -05:00
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
2025-11-21 15:09:24 -05:00
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, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}