mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
* feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist Signed-off-by: Deluan <deluan@navidrome.org> * feat(deezer): add language support for Deezer API client Signed-off-by: Deluan <deluan@navidrome.org> * 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> * refactor(deezer): move JWT token handling to a separate file for better organization Signed-off-by: Deluan <deluan@navidrome.org> * feat(deezer): enhance JWT token handling with expiration validation Signed-off-by: Deluan <deluan@navidrome.org> * refactor(deezer): change log level for unknown agent warnings from Warn to Debug Signed-off-by: Deluan <deluan@navidrome.org> * fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
102 lines
2.2 KiB
Go
102 lines
2.2 KiB
Go
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
|
|
}
|