mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
11 Commits
4e88346eec
...
04fdd34d91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04fdd34d91 | ||
|
|
f6b2ab5726 | ||
|
|
67c4e24957 | ||
|
|
255ed1f8e2 | ||
|
|
152f57e642 | ||
|
|
5c16622501 | ||
|
|
36fa869329 | ||
|
|
578375ca71 | ||
|
|
187eac268f | ||
|
|
f085446cc6 | ||
|
|
b12e3ff367 |
2
Makefile
2
Makefile
@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.5.0
|
||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
@ -374,6 +374,7 @@ func init() {
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
@ -397,6 +398,7 @@ func init() {
|
||||
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
||||
|
||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
|
||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableInsightsCollector bool
|
||||
@ -176,7 +177,8 @@ type spotifyOptions struct {
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
Enabled bool
|
||||
Enabled bool
|
||||
Language string
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
@ -490,6 +492,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@ -566,6 +569,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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, "</p>", "\n")
|
||||
return strictPolicy.Sanitize(bio)
|
||||
}
|
||||
|
||||
101
core/agents/deezer/client_auth.go
Normal file
101
core/agents/deezer/client_auth.go
Normal file
@ -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
|
||||
}
|
||||
293
core/agents/deezer/client_auth_test.go
Normal file
293
core/agents/deezer/client_auth_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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("<p>"))
|
||||
Expect(bio).ToNot(ContainSubstring("</p>"))
|
||||
})
|
||||
|
||||
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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -127,8 +127,8 @@ type ffCmd struct {
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *ffCmd) start() error {
|
||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
|
||||
BeforeEach(func() {
|
||||
ffOnce = sync.Once{}
|
||||
ff = New()
|
||||
// Skip if FFmpeg is not available
|
||||
if !ff.IsAvailable() {
|
||||
Skip("FFmpeg not available on this system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should interrupt transcoding when context is cancelled", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a command that generates audio indefinitely
|
||||
// -f lavfi uses FFmpeg's built-in audio source
|
||||
// -t 0 means no time limit (runs forever)
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Read some data first to ensure FFmpeg is running
|
||||
buf := make([]byte, 1024)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Next read should fail due to cancelled context
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle immediate context cancellation", func() {
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with mock process behavior", func() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
// Use a long-running command for testing cancellation
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
// Use PowerShell's Start-Sleep
|
||||
ffmpegPath = "powershell"
|
||||
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
|
||||
default:
|
||||
// Use sleep on Unix-like systems
|
||||
ffmpegPath = "sleep"
|
||||
longRunningCmd = "sleep 10"
|
||||
}
|
||||
})
|
||||
|
||||
It("should terminate the underlying process when context is cancelled", func() {
|
||||
ff := New()
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Give the process time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Try to read from the stream, which should fail
|
||||
buf := make([]byte, 100)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
|
||||
|
||||
// Verify the stream is closed by attempting another read
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
|
||||
2
go.mod
2
go.mod
@ -1,6 +1,6 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.25.4
|
||||
go 1.25
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
@ -119,7 +119,7 @@ func (r sqlRepository) cleanAnnotations() error {
|
||||
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error cleaning up annotations: %w", err)
|
||||
return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
|
||||
|
||||
@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
|
||||
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error cleaning up bookmarks: %w", err)
|
||||
return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
|
||||
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
|
||||
`)
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error purging unused tags: %w", err)
|
||||
return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
|
||||
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
9
tests/fixtures/deezer.artist.bio.json
vendored
Normal file
9
tests/fixtures/deezer.artist.bio.json
vendored
Normal 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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/fixtures/deezer.artist.related.json
vendored
Normal file
1
tests/fixtures/deezer.artist.related.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
tests/fixtures/deezer.artist.top.json
vendored
Normal file
1
tests/fixtures/deezer.artist.top.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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 }) => (
|
||||
<Button {...rest}>
|
||||
{loading ? <CircularProgress size={20} color="inherit" /> : icon}
|
||||
</Button>
|
||||
)
|
||||
|
||||
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)}
|
||||
>
|
||||
<Button
|
||||
<LoadingButton
|
||||
onClick={handlePlay}
|
||||
label={translate('resources.artist.actions.topSongs')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'play'}
|
||||
icon={<PlayArrowIcon />}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleShuffle}
|
||||
label={translate('resources.artist.actions.shuffle')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'shuffle'}
|
||||
icon={<ShuffleIcon />}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleRadio}
|
||||
label={translate('resources.artist.actions.radio')}
|
||||
className={classes.button}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<IoIosRadio className={classes.radioIcon} />
|
||||
</Button>
|
||||
disabled={isLoading}
|
||||
loading={loadingAction === 'radio'}
|
||||
icon={<IoIosRadio className={classes.radioIcon} />}
|
||||
/>
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user