package deezerimport ("bytes""context""encoding/json""errors""fmt""io""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"))type httpDoer interface {Do(req *http.Request) (*http.Response, error)}type client struct {httpDoer httpDoerjwt jwtToken}func newClient(hc httpDoer) *client {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 SearchArtistResultserr = c.makeRequest(req, &results)if err != nil {return nil, err}if len(results.Data) == 0 {return nil, ErrNotFound}return results.Data, nil}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}if resp.StatusCode != 200 {return c.parseError(data)}return json.Unmarshal(data, response)}func (c *client) parseError(data []byte) error {var deezerError Errorerr := json.Unmarshal(data, &deezerError)if err != nil {return err}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 RelatedArtistserr = 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 TopTrackserr = 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) {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)req.Header.Set("Authorization", "Bearer "+jwt)log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)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 graphQLResponseif 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 []errorfor 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)}