mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
18 Commits
49822210b2
...
3a1b6d8465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1b6d8465 | ||
|
|
67c4e24957 | ||
|
|
255ed1f8e2 | ||
|
|
152f57e642 | ||
|
|
5c16622501 | ||
|
|
36fa869329 | ||
|
|
59427e29ab | ||
|
|
3d2f7148e7 | ||
|
|
f0c0e804fa | ||
|
|
4bbcbc17f1 | ||
|
|
6a9ccb309c | ||
|
|
efeaa80de9 | ||
|
|
f54229ea50 | ||
|
|
a82a03feda | ||
|
|
d3c2beabd8 | ||
|
|
e3534fa56b | ||
|
|
b342035884 | ||
|
|
b0e2457e61 |
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
|
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
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/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|||||||
@ -176,7 +176,8 @@ type spotifyOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerOptions struct {
|
type deezerOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
Language string
|
||||||
}
|
}
|
||||||
|
|
||||||
type listenBrainzOptions struct {
|
type listenBrainzOptions struct {
|
||||||
@ -566,6 +567,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("spotify.id", "")
|
viper.SetDefault("spotify.id", "")
|
||||||
viper.SetDefault("spotify.secret", "")
|
viper.SetDefault("spotify.secret", "")
|
||||||
viper.SetDefault("deezer.enabled", true)
|
viper.SetDefault("deezer.enabled", true)
|
||||||
|
viper.SetDefault("deezer.language", "en")
|
||||||
viper.SetDefault("listenbrainz.enabled", true)
|
viper.SetDefault("listenbrainz.enabled", true)
|
||||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||||
|
|||||||
@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
|||||||
} else if isPlugin {
|
} else if isPlugin {
|
||||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||||
} else {
|
} else {
|
||||||
log.Warn("Unknown agent ignored", "name", name)
|
log.Debug("Unknown agent ignored", "name", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return validAgents
|
return validAgents
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
bytes "bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -9,11 +10,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiBaseURL = "https://api.deezer.com"
|
const apiBaseURL = "https://api.deezer.com"
|
||||||
|
const authBaseURL = "https://auth.deezer.com"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("deezer: not found")
|
ErrNotFound = errors.New("deezer: not found")
|
||||||
@ -25,10 +29,15 @@ type httpDoer interface {
|
|||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
httpDoer httpDoer
|
httpDoer httpDoer
|
||||||
|
language string
|
||||||
|
jwt jwtToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func newClient(hc httpDoer) *client {
|
func newClient(hc httpDoer, language string) *client {
|
||||||
return &client{hc}
|
return &client{
|
||||||
|
httpDoer: hc,
|
||||||
|
language: language,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
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
|
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)
|
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
|
||||||
resp, err := c.httpDoer.Do(req)
|
resp, err := c.httpDoer.Do(req)
|
||||||
if err != nil {
|
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)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -17,7 +18,7 @@ var _ = Describe("client", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &fakeHttpClient{}
|
httpClient = &fakeHttpClient{}
|
||||||
client = newClient(httpClient)
|
client = newClient(httpClient, "en")
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ArtistImages", func() {
|
Describe("ArtistImages", func() {
|
||||||
@ -26,7 +27,7 @@ var _ = Describe("client", func() {
|
|||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
|
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(err).To(BeNil())
|
||||||
Expect(artists).To(HaveLen(17))
|
Expect(artists).To(HaveLen(17))
|
||||||
Expect(artists[0].Name).To(Equal("Michael Jackson"))
|
Expect(artists[0].Name).To(Equal("Michael Jackson"))
|
||||||
@ -39,10 +40,136 @@ var _ = Describe("client", func() {
|
|||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
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))
|
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 {
|
type fakeHttpClient struct {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deezerAgentName = "deezer"
|
const deezerAgentName = "deezer"
|
||||||
@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
|||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||||
agent.client = newClient(cachedHttpClient)
|
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
|||||||
return &artists[0], err
|
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() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server.Deezer.Enabled {
|
if conf.Server.Deezer.Enabled {
|
||||||
|
|||||||
@ -29,3 +29,38 @@ type Error struct {
|
|||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
} `json:"error"`
|
} `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"))
|
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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -1,6 +1,6 @@
|
|||||||
module github.com/navidrome/navidrome
|
module github.com/navidrome/navidrome
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25
|
||||||
|
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
// 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
|
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 + ")")
|
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||||
c, err := r.executeSQL(del)
|
c, err := r.executeSQL(del)
|
||||||
if err != nil {
|
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 {
|
if c > 0 {
|
||||||
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
|
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 + ")")
|
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||||
c, err := r.executeSQL(del)
|
c, err := r.executeSQL(del)
|
||||||
if err != nil {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
|
|||||||
`)
|
`)
|
||||||
c, err := r.executeSQL(del)
|
c, err := r.executeSQL(del)
|
||||||
if err != nil {
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
|||||||
defer p.measure(entry)()
|
defer p.measure(entry)()
|
||||||
p.state.changesDetected.Store(true)
|
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 {
|
err := p.ds.WithTx(func(tx model.DataStore) error {
|
||||||
// Instantiate all repositories just once per folder
|
// Instantiate all repositories just once per folder
|
||||||
folderRepo := tx.Folder(p.ctx)
|
folderRepo := tx.Folder(p.ctx)
|
||||||
@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if entry.albums[i].Name != consts.UnknownAlbum {
|
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 {
|
if err != nil {
|
||||||
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
|
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
|
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,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { useMediaQuery } from '@material-ui/core'
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
import { ThemeProvider } from '@material-ui/core/styles'
|
import { ThemeProvider } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
@ -16,32 +16,28 @@ import useCurrentTheme from '../themes/useCurrentTheme'
|
|||||||
import config from '../config'
|
import config from '../config'
|
||||||
import useStyle from './styles'
|
import useStyle from './styles'
|
||||||
import AudioTitle from './AudioTitle'
|
import AudioTitle from './AudioTitle'
|
||||||
import {
|
|
||||||
clearQueue,
|
|
||||||
currentPlaying,
|
|
||||||
setPlayMode,
|
|
||||||
setVolume,
|
|
||||||
syncQueue,
|
|
||||||
} from '../actions'
|
|
||||||
import PlayerToolbar from './PlayerToolbar'
|
import PlayerToolbar from './PlayerToolbar'
|
||||||
import { sendNotification } from '../utils'
|
import { sendNotification } from '../utils'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import locale from './locale'
|
import locale from './locale'
|
||||||
import { keyMap } from '../hotkeys'
|
import { keyMap } from '../hotkeys'
|
||||||
import keyHandlers from './keyHandlers'
|
import keyHandlers from './keyHandlers'
|
||||||
import { calculateGain } from '../utils/calculateReplayGain'
|
import { useScrobbling } from './hooks/useScrobbling'
|
||||||
|
import { useReplayGain } from './hooks/useReplayGain'
|
||||||
|
import { usePreloading } from './hooks/usePreloading'
|
||||||
|
import { usePlayerState } from './hooks/usePlayerState'
|
||||||
|
import { useAudioInstance } from './hooks/useAudioInstance'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player component for Navidrome music streaming application.
|
||||||
|
* Renders an audio player with scrobbling, replay gain, preloading, and other features.
|
||||||
|
*
|
||||||
|
* @returns {JSX.Element} The rendered Player component.
|
||||||
|
*/
|
||||||
const Player = () => {
|
const Player = () => {
|
||||||
const theme = useCurrentTheme()
|
const theme = useCurrentTheme()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const playerTheme = theme.player?.theme || 'dark'
|
const playerTheme = theme.player?.theme || 'dark'
|
||||||
const dataProvider = useDataProvider()
|
const dataProvider = useDataProvider()
|
||||||
const playerState = useSelector((state) => state.player)
|
|
||||||
const dispatch = useDispatch()
|
|
||||||
const [startTime, setStartTime] = useState(null)
|
|
||||||
const [scrobbled, setScrobbled] = useState(false)
|
|
||||||
const [preloaded, setPreload] = useState(false)
|
|
||||||
const [audioInstance, setAudioInstance] = useState(null)
|
|
||||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||||
const isMobilePlayer =
|
const isMobilePlayer =
|
||||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
@ -49,6 +45,39 @@ const Player = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { authenticated } = useAuthState()
|
const { authenticated } = useAuthState()
|
||||||
|
const showNotifications = useSelector(
|
||||||
|
(state) => state.settings.notifications || false,
|
||||||
|
)
|
||||||
|
const gainInfo = useSelector((state) => state.replayGain)
|
||||||
|
|
||||||
|
// Custom hooks for separated concerns
|
||||||
|
const {
|
||||||
|
playerState,
|
||||||
|
dispatch,
|
||||||
|
dispatchCurrentPlaying,
|
||||||
|
dispatchSetPlayMode,
|
||||||
|
dispatchSetVolume,
|
||||||
|
dispatchSyncQueue,
|
||||||
|
dispatchClearQueue,
|
||||||
|
} = usePlayerState()
|
||||||
|
|
||||||
|
const {
|
||||||
|
startTime,
|
||||||
|
setStartTime,
|
||||||
|
scrobbled,
|
||||||
|
onAudioProgress,
|
||||||
|
onAudioPlayTrackChange,
|
||||||
|
onAudioEnded,
|
||||||
|
} = useScrobbling(playerState, dispatch, dataProvider)
|
||||||
|
|
||||||
|
const { preloaded, preloadNextSong, resetPreloading } =
|
||||||
|
usePreloading(playerState)
|
||||||
|
|
||||||
|
const { audioInstance, setAudioInstance, onAudioPlay } =
|
||||||
|
useAudioInstance(isMobilePlayer)
|
||||||
|
|
||||||
|
const { context } = useReplayGain(audioInstance, playerState, gainInfo)
|
||||||
|
|
||||||
const visible = authenticated && playerState.queue.length > 0
|
const visible = authenticated && playerState.queue.length > 0
|
||||||
const isRadio = playerState.current?.isRadio || false
|
const isRadio = playerState.current?.isRadio || false
|
||||||
const classes = useStyle({
|
const classes = useStyle({
|
||||||
@ -56,44 +85,6 @@ const Player = () => {
|
|||||||
visible,
|
visible,
|
||||||
enableCoverAnimation: config.enableCoverAnimation,
|
enableCoverAnimation: config.enableCoverAnimation,
|
||||||
})
|
})
|
||||||
const showNotifications = useSelector(
|
|
||||||
(state) => state.settings.notifications || false,
|
|
||||||
)
|
|
||||||
const gainInfo = useSelector((state) => state.replayGain)
|
|
||||||
const [context, setContext] = useState(null)
|
|
||||||
const [gainNode, setGainNode] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
context === null &&
|
|
||||||
audioInstance &&
|
|
||||||
config.enableReplayGain &&
|
|
||||||
'AudioContext' in window &&
|
|
||||||
(gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track')
|
|
||||||
) {
|
|
||||||
const ctx = new AudioContext()
|
|
||||||
// we need this to support radios in firefox
|
|
||||||
audioInstance.crossOrigin = 'anonymous'
|
|
||||||
const source = ctx.createMediaElementSource(audioInstance)
|
|
||||||
const gain = ctx.createGain()
|
|
||||||
|
|
||||||
source.connect(gain)
|
|
||||||
gain.connect(ctx.destination)
|
|
||||||
|
|
||||||
setContext(ctx)
|
|
||||||
setGainNode(gain)
|
|
||||||
}
|
|
||||||
}, [audioInstance, context, gainInfo.gainMode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gainNode) {
|
|
||||||
const current = playerState.current || {}
|
|
||||||
const song = current.song || {}
|
|
||||||
|
|
||||||
const numericGain = calculateGain(gainInfo, song)
|
|
||||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
|
||||||
}
|
|
||||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
|
||||||
|
|
||||||
const defaultOptions = useMemo(
|
const defaultOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -129,140 +120,84 @@ const Player = () => {
|
|||||||
locale: locale(translate),
|
locale: locale(translate),
|
||||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||||
}),
|
}),
|
||||||
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
|
[playerTheme, playerState.mode, isDesktop, gainInfo, translate],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Memoize expensive computations
|
||||||
|
const audioLists = useMemo(
|
||||||
|
() => playerState.queue.map((item) => item),
|
||||||
|
[playerState.queue],
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentTrack = playerState.current || {}
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const current = playerState.current || {}
|
|
||||||
return {
|
return {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
audioLists: playerState.queue.map((item) => item),
|
audioLists,
|
||||||
playIndex: playerState.playIndex,
|
playIndex: playerState.playIndex,
|
||||||
autoPlay: playerState.clear || playerState.playIndex === 0,
|
autoPlay: playerState.clear || playerState.playIndex === 0,
|
||||||
clearPriorAudioLists: playerState.clear,
|
clearPriorAudioLists: playerState.clear,
|
||||||
extendsContent: (
|
extendsContent: (
|
||||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
<PlayerToolbar
|
||||||
|
id={currentTrack.trackId}
|
||||||
|
isRadio={currentTrack.isRadio}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||||
showMediaSession: !current.isRadio,
|
showMediaSession: !currentTrack.isRadio,
|
||||||
}
|
}
|
||||||
}, [playerState, defaultOptions, isMobilePlayer])
|
}, [
|
||||||
|
defaultOptions,
|
||||||
|
audioLists,
|
||||||
|
playerState.playIndex,
|
||||||
|
playerState.clear,
|
||||||
|
playerState.volume,
|
||||||
|
isMobilePlayer,
|
||||||
|
currentTrack.trackId,
|
||||||
|
currentTrack.isRadio,
|
||||||
|
])
|
||||||
|
|
||||||
const onAudioListsChange = useCallback(
|
const onAudioListsChange = useCallback(
|
||||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
(_, audioLists, audioInfo) => dispatchSyncQueue(audioInfo, audioLists),
|
||||||
[dispatch],
|
[dispatchSyncQueue],
|
||||||
)
|
|
||||||
|
|
||||||
const nextSong = useCallback(() => {
|
|
||||||
const idx = playerState.queue.findIndex(
|
|
||||||
(item) => item.uuid === playerState.current.uuid,
|
|
||||||
)
|
|
||||||
return idx !== null ? playerState.queue[idx + 1] : null
|
|
||||||
}, [playerState])
|
|
||||||
|
|
||||||
const onAudioProgress = useCallback(
|
|
||||||
(info) => {
|
|
||||||
if (info.ended) {
|
|
||||||
document.title = 'Navidrome'
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = (info.currentTime / info.duration) * 100
|
|
||||||
if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.isRadio) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preloaded) {
|
|
||||||
const next = nextSong()
|
|
||||||
if (next != null) {
|
|
||||||
const audio = new Audio()
|
|
||||||
audio.src = next.musicSrc
|
|
||||||
}
|
|
||||||
setPreload(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scrobbled) {
|
|
||||||
info.trackId && subsonic.scrobble(info.trackId, startTime)
|
|
||||||
setScrobbled(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[startTime, scrobbled, nextSong, preloaded],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioVolumeChange = useCallback(
|
const onAudioVolumeChange = useCallback(
|
||||||
// sqrt to compensate for the logarithmic volume
|
// sqrt to compensate for the logarithmic volume
|
||||||
(volume) => dispatch(setVolume(Math.sqrt(volume))),
|
(volume) => dispatchSetVolume(volume),
|
||||||
[dispatch],
|
[dispatchSetVolume],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioPlay = useCallback(
|
const handleAudioPlay = useCallback(
|
||||||
(info) => {
|
(info) => {
|
||||||
// Do this to start the context; on chrome-based browsers, the context
|
onAudioPlay(
|
||||||
// will start paused since it is created prior to user interaction
|
context,
|
||||||
if (context && context.state !== 'running') {
|
info,
|
||||||
context.resume()
|
(info) => dispatchCurrentPlaying(info),
|
||||||
}
|
showNotifications,
|
||||||
|
sendNotification,
|
||||||
dispatch(currentPlaying(info))
|
startTime,
|
||||||
if (startTime === null) {
|
setStartTime,
|
||||||
setStartTime(Date.now())
|
resetPreloading,
|
||||||
}
|
config,
|
||||||
if (info.duration) {
|
ReactGA,
|
||||||
const song = info.song
|
)
|
||||||
document.title = `${song.title} - ${song.artist} - Navidrome`
|
|
||||||
if (!info.isRadio) {
|
|
||||||
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
|
||||||
subsonic.nowPlaying(info.trackId, pos)
|
|
||||||
}
|
|
||||||
setPreload(false)
|
|
||||||
if (config.gaTrackingId) {
|
|
||||||
ReactGA.event({
|
|
||||||
category: 'Player',
|
|
||||||
action: 'Play song',
|
|
||||||
label: `${song.title} - ${song.artist}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (showNotifications) {
|
|
||||||
sendNotification(
|
|
||||||
song.title,
|
|
||||||
`${song.artist} - ${song.album}`,
|
|
||||||
info.cover,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[context, dispatch, showNotifications, startTime],
|
[
|
||||||
|
onAudioPlay,
|
||||||
|
context,
|
||||||
|
dispatchCurrentPlaying,
|
||||||
|
showNotifications,
|
||||||
|
startTime,
|
||||||
|
setStartTime,
|
||||||
|
resetPreloading,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioPlayTrackChange = useCallback(() => {
|
|
||||||
if (scrobbled) {
|
|
||||||
setScrobbled(false)
|
|
||||||
}
|
|
||||||
if (startTime !== null) {
|
|
||||||
setStartTime(null)
|
|
||||||
}
|
|
||||||
}, [scrobbled, startTime])
|
|
||||||
|
|
||||||
const onAudioPause = useCallback(
|
const onAudioPause = useCallback(
|
||||||
(info) => dispatch(currentPlaying(info)),
|
(info) => dispatchCurrentPlaying(info),
|
||||||
[dispatch],
|
[dispatchCurrentPlaying],
|
||||||
)
|
|
||||||
|
|
||||||
const onAudioEnded = useCallback(
|
|
||||||
(currentPlayId, audioLists, info) => {
|
|
||||||
setScrobbled(false)
|
|
||||||
setStartTime(null)
|
|
||||||
dispatch(currentPlaying(info))
|
|
||||||
dataProvider
|
|
||||||
.getOne('keepalive', { id: info.trackId })
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
.catch((e) => console.log('Keepalive error:', e))
|
|
||||||
},
|
|
||||||
[dispatch, dataProvider],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||||
@ -273,10 +208,10 @@ const Player = () => {
|
|||||||
|
|
||||||
const onBeforeDestroy = useCallback(() => {
|
const onBeforeDestroy = useCallback(() => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
dispatch(clearQueue())
|
dispatchClearQueue()
|
||||||
reject()
|
reject()
|
||||||
})
|
})
|
||||||
}, [dispatch])
|
}, [dispatchClearQueue])
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
document.title = 'Navidrome'
|
document.title = 'Navidrome'
|
||||||
@ -287,30 +222,32 @@ const Player = () => {
|
|||||||
[audioInstance, playerState],
|
[audioInstance, playerState],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobilePlayer && audioInstance) {
|
|
||||||
audioInstance.volume = 1
|
|
||||||
}
|
|
||||||
}, [isMobilePlayer, audioInstance])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||||
<ReactJkMusicPlayer
|
<div role="region" aria-label="Audio Player" aria-live="polite">
|
||||||
{...options}
|
<ReactJkMusicPlayer
|
||||||
className={classes.player}
|
{...options}
|
||||||
onAudioListsChange={onAudioListsChange}
|
className={classes.player}
|
||||||
onAudioVolumeChange={onAudioVolumeChange}
|
onAudioListsChange={onAudioListsChange}
|
||||||
onAudioProgress={onAudioProgress}
|
onAudioVolumeChange={onAudioVolumeChange}
|
||||||
onAudioPlay={onAudioPlay}
|
onAudioProgress={onAudioProgress}
|
||||||
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
onAudioPlay={handleAudioPlay}
|
||||||
onAudioPause={onAudioPause}
|
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
||||||
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
|
onAudioPause={onAudioPause}
|
||||||
onAudioEnded={onAudioEnded}
|
onPlayModeChange={dispatchSetPlayMode}
|
||||||
onCoverClick={onCoverClick}
|
onAudioEnded={onAudioEnded}
|
||||||
onBeforeDestroy={onBeforeDestroy}
|
onCoverClick={onCoverClick}
|
||||||
getAudioInstance={setAudioInstance}
|
onBeforeDestroy={onBeforeDestroy}
|
||||||
/>
|
getAudioInstance={setAudioInstance}
|
||||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
aria-label="Music Player"
|
||||||
|
/>
|
||||||
|
<GlobalHotKeys
|
||||||
|
handlers={handlers}
|
||||||
|
keyMap={keyMap}
|
||||||
|
allowChanges
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
166
ui/src/audioplayer/Player.test.jsx
Normal file
166
ui/src/audioplayer/Player.test.jsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||||
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
|
import { useGetOne } from 'react-admin'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { useToggleLove } from '../common'
|
||||||
|
import { openSaveQueueDialog } from '../actions'
|
||||||
|
import PlayerToolbar from './PlayerToolbar'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@material-ui/core', async () => {
|
||||||
|
const actual = await import('@material-ui/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useMediaQuery: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('react-admin', () => ({
|
||||||
|
useGetOne: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-redux', () => ({
|
||||||
|
useDispatch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../common', () => ({
|
||||||
|
LoveButton: ({ className, disabled }) => (
|
||||||
|
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||||
|
Love
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
useToggleLove: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../actions', () => ({
|
||||||
|
openSaveQueueDialog: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-hotkeys', () => ({
|
||||||
|
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('<PlayerToolbar />', () => {
|
||||||
|
const mockToggleLove = vi.fn()
|
||||||
|
const mockDispatch = vi.fn()
|
||||||
|
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||||
|
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||||
|
useDispatch.mockReturnValue(mockDispatch)
|
||||||
|
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
describe('Desktop layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders desktop toolbar with both buttons', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
// Both buttons should be in a single list item
|
||||||
|
const listItems = screen.getAllByRole('listitem')
|
||||||
|
expect(listItems).toHaveLength(1)
|
||||||
|
|
||||||
|
// Verify both buttons are rendered
|
||||||
|
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify desktop classes are applied
|
||||||
|
expect(listItems[0].className).toContain('toolbar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables save queue button when isRadio is true', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
expect(saveQueueButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables love button when conditions are met', () => {
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||||
|
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens save queue dialog when save button is clicked', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
fireEvent.click(saveQueueButton)
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
|
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
// Each button should be in its own list item
|
||||||
|
const listItems = screen.getAllByRole('listitem')
|
||||||
|
expect(listItems).toHaveLength(2)
|
||||||
|
|
||||||
|
// Verify both buttons are rendered
|
||||||
|
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Verify mobile classes are applied
|
||||||
|
expect(listItems[0].className).toContain('mobileListItem')
|
||||||
|
expect(listItems[1].className).toContain('mobileListItem')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables save queue button when isRadio is true', () => {
|
||||||
|
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||||
|
|
||||||
|
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||||
|
expect(saveQueueButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables love button when conditions are met', () => {
|
||||||
|
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||||
|
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Common behavior', () => {
|
||||||
|
it('renders global hotkeys in both layouts', () => {
|
||||||
|
// Test desktop layout
|
||||||
|
useMediaQuery.mockReturnValue(true)
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Cleanup and test mobile layout
|
||||||
|
cleanup()
|
||||||
|
useMediaQuery.mockReturnValue(false)
|
||||||
|
render(<PlayerToolbar id="song-1" />)
|
||||||
|
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables buttons when id is not provided', () => {
|
||||||
|
render(<PlayerToolbar />)
|
||||||
|
|
||||||
|
const loveButton = screen.getByTestId('love-button')
|
||||||
|
expect(loveButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal file
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import subsonic from '../../subsonic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing the audio instance and related effects.
|
||||||
|
* Handles audio element setup and mobile volume adjustments.
|
||||||
|
*
|
||||||
|
* @param {boolean} isMobilePlayer - Whether the player is running on a mobile device.
|
||||||
|
* @returns {Object} Audio instance-related state and handlers.
|
||||||
|
* @returns {HTMLAudioElement|null} audioInstance - The audio element instance.
|
||||||
|
* @returns {Function} setAudioInstance - Setter for the audio instance.
|
||||||
|
* @returns {Function} onAudioPlay - Handler for audio play events.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { audioInstance, setAudioInstance, onAudioPlay } = useAudioInstance(isMobilePlayer);
|
||||||
|
*/
|
||||||
|
export const useAudioInstance = (isMobilePlayer) => {
|
||||||
|
const [audioInstance, setAudioInstance] = useState(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles audio play events, resuming context if needed and updating document title.
|
||||||
|
*
|
||||||
|
* @param {AudioContext|null} audioContext - Web Audio API context from replay gain hook.
|
||||||
|
* @param {Object} info - Audio play information.
|
||||||
|
* @param {Object} info.song - Song metadata.
|
||||||
|
* @param {number} info.duration - Track duration.
|
||||||
|
* @param {boolean} info.isRadio - Whether it's a radio stream.
|
||||||
|
* @param {string} info.trackId - Track identifier.
|
||||||
|
* @param {number} info.currentTime - Current playback time.
|
||||||
|
* @param {Function} dispatchCurrentPlaying - Function to dispatch current playing action.
|
||||||
|
* @param {boolean} showNotifications - Whether to show notifications.
|
||||||
|
* @param {Function} sendNotification - Function to send notifications.
|
||||||
|
* @param {number|null} startTime - Start time for scrobbling.
|
||||||
|
* @param {Function} setStartTime - Setter for start time.
|
||||||
|
* @param {Function} resetPreloading - Function to reset preloading.
|
||||||
|
* @param {Object} config - Application configuration.
|
||||||
|
* @param {Object} ReactGA - Google Analytics instance.
|
||||||
|
*/
|
||||||
|
const onAudioPlay = useCallback(
|
||||||
|
(
|
||||||
|
audioContext,
|
||||||
|
info,
|
||||||
|
dispatchCurrentPlaying,
|
||||||
|
showNotifications,
|
||||||
|
sendNotification,
|
||||||
|
startTime,
|
||||||
|
setStartTime,
|
||||||
|
resetPreloading,
|
||||||
|
config,
|
||||||
|
ReactGA,
|
||||||
|
) => {
|
||||||
|
// Resume audio context if suspended
|
||||||
|
if (audioContext && audioContext.state !== 'running') {
|
||||||
|
try {
|
||||||
|
audioContext.resume()
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Error resuming audio context:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchCurrentPlaying(info)
|
||||||
|
|
||||||
|
if (startTime === null) {
|
||||||
|
setStartTime(Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.duration) {
|
||||||
|
const song = info.song
|
||||||
|
document.title = `${song.title} - ${song.artist} - Navidrome`
|
||||||
|
|
||||||
|
if (!info.isRadio) {
|
||||||
|
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
||||||
|
try {
|
||||||
|
subsonic.nowPlaying(info.trackId, pos)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Error updating now playing:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPreloading()
|
||||||
|
|
||||||
|
if (config.gaTrackingId) {
|
||||||
|
try {
|
||||||
|
ReactGA.event({
|
||||||
|
category: 'Player',
|
||||||
|
action: 'Play song',
|
||||||
|
label: `${song.title} - ${song.artist}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Google Analytics error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNotifications) {
|
||||||
|
try {
|
||||||
|
sendNotification(
|
||||||
|
song.title,
|
||||||
|
`${song.artist} - ${song.album}`,
|
||||||
|
info.cover,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Notification error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mobile volume adjustment effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobilePlayer && audioInstance) {
|
||||||
|
try {
|
||||||
|
audioInstance.volume = 1
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Error setting mobile volume:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isMobilePlayer, audioInstance])
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioInstance,
|
||||||
|
setAudioInstance,
|
||||||
|
onAudioPlay,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal file
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useSelector, useDispatch } from 'react-redux'
|
||||||
|
import {
|
||||||
|
clearQueue,
|
||||||
|
currentPlaying,
|
||||||
|
setPlayMode,
|
||||||
|
setVolume,
|
||||||
|
syncQueue,
|
||||||
|
} from '../../actions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing player state and actions via Redux.
|
||||||
|
* Centralizes access to player-related state and dispatch functions.
|
||||||
|
*
|
||||||
|
* @returns {Object} Player state and action dispatchers.
|
||||||
|
* @returns {Object} playerState - Current player state from Redux store.
|
||||||
|
* @returns {Function} dispatch - Redux dispatch function.
|
||||||
|
* @returns {Function} dispatchCurrentPlaying - Dispatches current playing action.
|
||||||
|
* @returns {Function} dispatchSetPlayMode - Dispatches set play mode action.
|
||||||
|
* @returns {Function} dispatchSetVolume - Dispatches set volume action.
|
||||||
|
* @returns {Function} dispatchSyncQueue - Dispatches sync queue action.
|
||||||
|
* @returns {Function} dispatchClearQueue - Dispatches clear queue action.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { playerState, dispatchCurrentPlaying } = usePlayerState();
|
||||||
|
*/
|
||||||
|
export const usePlayerState = () => {
|
||||||
|
const playerState = useSelector((state) => state.player)
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the current playing action.
|
||||||
|
*
|
||||||
|
* @param {Object} info - Audio information.
|
||||||
|
*/
|
||||||
|
const dispatchCurrentPlaying = (info) => {
|
||||||
|
dispatch(currentPlaying(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the set play mode action.
|
||||||
|
*
|
||||||
|
* @param {string} mode - Play mode (e.g., 'single', 'loop', 'shuffle').
|
||||||
|
*/
|
||||||
|
const dispatchSetPlayMode = (mode) => {
|
||||||
|
dispatch(setPlayMode(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the set volume action with square root compensation.
|
||||||
|
*
|
||||||
|
* @param {number} volume - Volume level (0-1).
|
||||||
|
*/
|
||||||
|
const dispatchSetVolume = (volume) => {
|
||||||
|
// sqrt to compensate for the logarithmic volume
|
||||||
|
dispatch(setVolume(Math.sqrt(volume)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the sync queue action.
|
||||||
|
*
|
||||||
|
* @param {Object} audioInfo - Audio information.
|
||||||
|
* @param {Array} audioLists - List of audio tracks.
|
||||||
|
*/
|
||||||
|
const dispatchSyncQueue = (audioInfo, audioLists) => {
|
||||||
|
dispatch(syncQueue(audioInfo, audioLists))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches the clear queue action.
|
||||||
|
*/
|
||||||
|
const dispatchClearQueue = () => {
|
||||||
|
dispatch(clearQueue())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerState,
|
||||||
|
dispatch,
|
||||||
|
dispatchCurrentPlaying,
|
||||||
|
dispatchSetPlayMode,
|
||||||
|
dispatchSetVolume,
|
||||||
|
dispatchSyncQueue,
|
||||||
|
dispatchClearQueue,
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal file
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
|
||||||
|
import { renderHook } from '@testing-library/react-hooks'
|
||||||
|
import { usePlayerState } from './usePlayerState'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import { describe, it, beforeEach, vi, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Mock react-redux
|
||||||
|
vi.mock('react-redux', () => ({
|
||||||
|
useDispatch: vi.fn(),
|
||||||
|
useSelector: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock actions
|
||||||
|
vi.mock('../../actions', () => ({
|
||||||
|
clearQueue: vi.fn(() => ({ type: 'CLEAR_QUEUE' })),
|
||||||
|
currentPlaying: vi.fn(() => ({ type: 'CURRENT_PLAYING' })),
|
||||||
|
setPlayMode: vi.fn(() => ({ type: 'SET_PLAY_MODE' })),
|
||||||
|
setVolume: vi.fn(() => ({ type: 'SET_VOLUME' })),
|
||||||
|
syncQueue: vi.fn(() => ({ type: 'SYNC_QUEUE' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import the mocked actions
|
||||||
|
import * as actions from '../../actions'
|
||||||
|
|
||||||
|
describe('usePlayerState', () => {
|
||||||
|
const mockPlayerState = {
|
||||||
|
queue: [],
|
||||||
|
current: null,
|
||||||
|
mode: 'single',
|
||||||
|
volume: 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDispatch = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useDispatch.mockReturnValue(mockDispatch)
|
||||||
|
useSelector.mockReturnValue(mockPlayerState)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return player state and dispatch functions', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
|
||||||
|
expect(result.current.playerState).toEqual(mockPlayerState)
|
||||||
|
expect(typeof result.current.dispatch).toBe('function')
|
||||||
|
expect(typeof result.current.dispatchCurrentPlaying).toBe('function')
|
||||||
|
expect(typeof result.current.dispatchSetPlayMode).toBe('function')
|
||||||
|
expect(typeof result.current.dispatchSetVolume).toBe('function')
|
||||||
|
expect(typeof result.current.dispatchSyncQueue).toBe('function')
|
||||||
|
expect(typeof result.current.dispatchClearQueue).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dispatch current playing action', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
const mockInfo = { trackId: 'track1' }
|
||||||
|
|
||||||
|
result.current.dispatchCurrentPlaying(mockInfo)
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CURRENT_PLAYING' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dispatch set play mode action', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
|
||||||
|
result.current.dispatchSetPlayMode('loop')
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_PLAY_MODE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dispatch set volume action with square root compensation', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
|
||||||
|
result.current.dispatchSetVolume(0.5)
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_VOLUME' })
|
||||||
|
// Verify square root calculation
|
||||||
|
expect(actions.setVolume).toHaveBeenCalledWith(Math.sqrt(0.5))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dispatch sync queue action', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
const mockAudioInfo = { trackId: 'track1' }
|
||||||
|
const mockAudioLists = [{ id: '1' }]
|
||||||
|
|
||||||
|
result.current.dispatchSyncQueue(mockAudioInfo, mockAudioLists)
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'SYNC_QUEUE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dispatch clear queue action', () => {
|
||||||
|
const { result } = renderHook(() => usePlayerState())
|
||||||
|
|
||||||
|
result.current.dispatchClearQueue()
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLEAR_QUEUE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use correct Redux hooks', () => {
|
||||||
|
renderHook(() => usePlayerState())
|
||||||
|
|
||||||
|
expect(useDispatch).toHaveBeenCalled()
|
||||||
|
expect(useSelector).toHaveBeenCalledWith(expect.any(Function))
|
||||||
|
})
|
||||||
|
})
|
||||||
72
ui/src/audioplayer/hooks/usePreloading.js
Normal file
72
ui/src/audioplayer/hooks/usePreloading.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing audio preloading functionality.
|
||||||
|
* Preloads the next song in the queue to improve playback continuity.
|
||||||
|
*
|
||||||
|
* @param {Object} playerState - The current player state from Redux store.
|
||||||
|
* @returns {Object} Preloading-related state and handlers.
|
||||||
|
* @returns {boolean} preloaded - Whether the next song has been preloaded.
|
||||||
|
* @returns {Function} preloadNextSong - Function to preload the next song.
|
||||||
|
* @returns {Function} resetPreloading - Function to reset preloading state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { preloaded, preloadNextSong } = usePreloading(playerState);
|
||||||
|
*/
|
||||||
|
export const usePreloading = (playerState) => {
|
||||||
|
const [preloaded, setPreloaded] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the next song in the queue.
|
||||||
|
*
|
||||||
|
* @returns {Object|null} The next song object or null if not found.
|
||||||
|
*/
|
||||||
|
const nextSong = useCallback(() => {
|
||||||
|
const idx = playerState.queue.findIndex(
|
||||||
|
(item) => item.uuid === playerState.current?.uuid,
|
||||||
|
)
|
||||||
|
return idx !== -1 ? playerState.queue[idx + 1] : null
|
||||||
|
}, [playerState])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads the next song by creating an Audio element.
|
||||||
|
* This helps reduce buffering delays during playback.
|
||||||
|
*/
|
||||||
|
const preloadNextSong = useCallback(() => {
|
||||||
|
if (!preloaded) {
|
||||||
|
const next = nextSong()
|
||||||
|
if (next != null) {
|
||||||
|
try {
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.src = next.musicSrc
|
||||||
|
// Optional: Add load event listeners for better control
|
||||||
|
audio.addEventListener('canplaythrough', () => {
|
||||||
|
// Preload complete
|
||||||
|
})
|
||||||
|
audio.addEventListener('error', (error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Preloading error:', error)
|
||||||
|
})
|
||||||
|
setPreloaded(true)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Error during preloading:', error)
|
||||||
|
// Continue without preloading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [preloaded, nextSong])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the preloading state. Useful for track changes or manual resets.
|
||||||
|
*/
|
||||||
|
const resetPreloading = useCallback(() => {
|
||||||
|
setPreloaded(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
preloaded,
|
||||||
|
preloadNextSong,
|
||||||
|
resetPreloading,
|
||||||
|
}
|
||||||
|
}
|
||||||
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal file
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react-hooks'
|
||||||
|
import { usePreloading } from './usePreloading'
|
||||||
|
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('usePreloading', () => {
|
||||||
|
const mockPlayerState = {
|
||||||
|
queue: [
|
||||||
|
{ uuid: '1', musicSrc: 'song1.mp3' },
|
||||||
|
{ uuid: '2', musicSrc: 'song2.mp3' },
|
||||||
|
],
|
||||||
|
current: { uuid: '1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Mock Audio constructor
|
||||||
|
global.Audio = vi.fn().mockImplementation(function () {
|
||||||
|
this.src = ''
|
||||||
|
this.addEventListener = vi.fn()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.Audio
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with preloaded false', () => {
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(false)
|
||||||
|
expect(typeof result.current.preloadNextSong).toBe('function')
|
||||||
|
expect(typeof result.current.resetPreloading).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preload next song when called', () => {
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(true)
|
||||||
|
expect(global.Audio).toHaveBeenCalled()
|
||||||
|
expect(global.Audio.mock.instances[0].src).toBe('song2.mp3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not preload if already preloaded', () => {
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(true)
|
||||||
|
|
||||||
|
// Call again - should not create new Audio instance
|
||||||
|
const audioCallCount = global.Audio.mock.calls.length
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(global.Audio.mock.calls.length).toBe(audioCallCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when no next song exists', () => {
|
||||||
|
const stateWithNoNext = {
|
||||||
|
queue: [{ uuid: '1', musicSrc: 'song1.mp3' }],
|
||||||
|
current: { uuid: '1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePreloading(stateWithNoNext))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(false)
|
||||||
|
expect(global.Audio).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset preloading state', () => {
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(true)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.resetPreloading()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.preloaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Audio constructor errors gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
global.Audio = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Audio creation failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Error during preloading:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
expect(result.current.preloaded).toBe(false) // Should remain false on error
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle audio load errors gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
global.Audio = vi.fn().mockImplementation(function () {
|
||||||
|
this.src = ''
|
||||||
|
this.addEventListener = vi.fn((event, callback) => {
|
||||||
|
if (event === 'error') {
|
||||||
|
callback(new Event('error'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePreloading(mockPlayerState))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.preloadNextSong()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Preloading error:',
|
||||||
|
expect.any(Event),
|
||||||
|
)
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal file
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { calculateGain } from '../../utils/calculateReplayGain'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing replay gain functionality using Web Audio API.
|
||||||
|
* Adjusts audio gain based on track or album replay gain metadata.
|
||||||
|
*
|
||||||
|
* @param {Object} audioInstance - The HTML audio element instance.
|
||||||
|
* @param {Object} playerState - The current player state from Redux store.
|
||||||
|
* @param {Object} gainInfo - Replay gain configuration from Redux store.
|
||||||
|
* @returns {Object} Replay gain-related state.
|
||||||
|
* @returns {AudioContext|null} context - Web Audio API context.
|
||||||
|
* @returns {GainNode|null} gainNode - Gain node for audio manipulation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { context, gainNode } = useReplayGain(audioInstance, playerState, gainInfo);
|
||||||
|
*/
|
||||||
|
export const useReplayGain = (audioInstance, playerState, gainInfo) => {
|
||||||
|
const [context, setContext] = useState(null)
|
||||||
|
const [gainNode, setGainNode] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
context === null &&
|
||||||
|
audioInstance &&
|
||||||
|
'AudioContext' in window &&
|
||||||
|
(gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const ctx = new AudioContext()
|
||||||
|
// Support radios in Firefox
|
||||||
|
if (audioInstance) {
|
||||||
|
audioInstance.crossOrigin = 'anonymous'
|
||||||
|
}
|
||||||
|
const source = ctx.createMediaElementSource(audioInstance)
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
|
||||||
|
source.connect(gain)
|
||||||
|
gain.connect(ctx.destination)
|
||||||
|
|
||||||
|
setContext(ctx)
|
||||||
|
setGainNode(gain)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
'Error initializing Web Audio API for replay gain:',
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
// Fallback: continue without replay gain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [audioInstance, context, gainInfo.gainMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gainNode && context) {
|
||||||
|
try {
|
||||||
|
const current = playerState.current || {}
|
||||||
|
const song = current.song || {}
|
||||||
|
|
||||||
|
const numericGain = calculateGain(gainInfo, song)
|
||||||
|
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Error applying replay gain:', error)
|
||||||
|
// Continue playback without gain adjustment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
gainNode,
|
||||||
|
}
|
||||||
|
}
|
||||||
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal file
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react-hooks'
|
||||||
|
import { useReplayGain } from './useReplayGain'
|
||||||
|
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Mock calculateGain utility
|
||||||
|
vi.mock('../../utils/calculateReplayGain', () => ({
|
||||||
|
calculateGain: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import the mocked module
|
||||||
|
import * as calculateReplayGain from '../../utils/calculateReplayGain'
|
||||||
|
|
||||||
|
describe('useReplayGain', () => {
|
||||||
|
const mockCalculateGain = calculateReplayGain.calculateGain
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Mock Web Audio API
|
||||||
|
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||||
|
this.createMediaElementSource = vi.fn(() => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
}))
|
||||||
|
this.createGain = vi.fn(() => ({
|
||||||
|
gain: {
|
||||||
|
setValueAtTime: vi.fn(),
|
||||||
|
},
|
||||||
|
connect: vi.fn(),
|
||||||
|
}))
|
||||||
|
this.currentTime = 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.AudioContext
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with null context and gainNode', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(null, { current: {} }, { gainMode: 'track' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current.context).toBeNull()
|
||||||
|
expect(result.current.gainNode).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create audio context when conditions are met', () => {
|
||||||
|
const mockAudioInstance = { crossOrigin: '' }
|
||||||
|
const mockPlayerState = {
|
||||||
|
current: { song: { title: 'Test Song' } },
|
||||||
|
}
|
||||||
|
const mockGainInfo = { gainMode: 'track' }
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(global.AudioContext).toHaveBeenCalled()
|
||||||
|
expect(result.current.context).toBeInstanceOf(AudioContext)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply gain when gainNode exists', () => {
|
||||||
|
const mockAudioInstance = { crossOrigin: '' }
|
||||||
|
const mockPlayerState = {
|
||||||
|
current: { song: { title: 'Test Song' } },
|
||||||
|
}
|
||||||
|
const mockGainInfo = { gainMode: 'track' }
|
||||||
|
|
||||||
|
mockCalculateGain.mockReturnValue(0.8)
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockCalculateGain).toHaveBeenCalledWith(
|
||||||
|
mockGainInfo,
|
||||||
|
mockPlayerState.current.song,
|
||||||
|
)
|
||||||
|
expect(result.current.gainNode.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||||
|
0.8,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Web Audio API errors gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
// Mock AudioContext to throw error
|
||||||
|
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||||
|
throw new Error('Web Audio API not supported')
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockAudioInstance = {}
|
||||||
|
const mockPlayerState = { current: {} }
|
||||||
|
const mockGainInfo = { gainMode: 'track' }
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Error initializing Web Audio API for replay gain:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
expect(result.current.context).toBeNull()
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gain application errors gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const mockAudioInstance = { crossOrigin: '' }
|
||||||
|
const mockPlayerState = {
|
||||||
|
current: { song: { title: 'Test Song' } },
|
||||||
|
}
|
||||||
|
const mockGainInfo = { gainMode: 'track' }
|
||||||
|
|
||||||
|
// Mock gain.setValueAtTime to throw error
|
||||||
|
const mockGainNode = {
|
||||||
|
gain: {
|
||||||
|
setValueAtTime: vi.fn(() => {
|
||||||
|
throw new Error('Gain application failed')
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
connect: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
global.AudioContext = vi.fn().mockImplementation(function () {
|
||||||
|
this.createMediaElementSource = vi.fn(() => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
}))
|
||||||
|
this.createGain = vi.fn(() => mockGainNode)
|
||||||
|
this.currentTime = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Error applying replay gain:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not initialize when gainMode is not album or track', () => {
|
||||||
|
const mockAudioInstance = {}
|
||||||
|
const mockPlayerState = { current: {} }
|
||||||
|
const mockGainInfo = { gainMode: 'off' }
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(global.AudioContext).not.toHaveBeenCalled()
|
||||||
|
expect(result.current.context).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal file
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import subsonic from '../../subsonic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing scrobbling functionality in the audio player.
|
||||||
|
* Handles scrobbling state and logic for tracking played songs to external services.
|
||||||
|
*
|
||||||
|
* @param {Object} playerState - The current player state from Redux store.
|
||||||
|
* @param {Function} dispatch - Redux dispatch function.
|
||||||
|
* @param {Object} dataProvider - Data provider for API calls.
|
||||||
|
* @returns {Object} Scrobbling-related state and handlers.
|
||||||
|
* @returns {number|null} startTime - Timestamp when playback started.
|
||||||
|
* @returns {boolean} scrobbled - Whether the current track has been scrobbled.
|
||||||
|
* @returns {Function} onAudioProgress - Handler for audio progress events.
|
||||||
|
* @returns {Function} onAudioPlayTrackChange - Handler for track change events.
|
||||||
|
* @returns {Function} onAudioEnded - Handler for audio ended events.
|
||||||
|
* @returns {Function} resetScrobbling - Function to reset scrobbling state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { startTime, scrobbled, onAudioProgress, onAudioEnded } = useScrobbling(playerState, dispatch, dataProvider);
|
||||||
|
*/
|
||||||
|
export const useScrobbling = (playerState, dispatch, dataProvider) => {
|
||||||
|
const [startTime, setStartTime] = useState(null)
|
||||||
|
const [scrobbled, setScrobbled] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles audio progress events for scrobbling logic.
|
||||||
|
* Scrobbles the track if it has been played for more than 50% or 4 minutes.
|
||||||
|
*
|
||||||
|
* @param {Object} info - Audio progress information.
|
||||||
|
* @param {number} info.currentTime - Current playback time.
|
||||||
|
* @param {number} info.duration - Total duration of the track.
|
||||||
|
* @param {boolean} info.isRadio - Whether the current track is a radio stream.
|
||||||
|
* @param {string} info.trackId - Unique identifier of the track.
|
||||||
|
*/
|
||||||
|
const onAudioProgress = useCallback(
|
||||||
|
(info) => {
|
||||||
|
if (info.ended) {
|
||||||
|
document.title = 'Navidrome'
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = (info.currentTime / info.duration) * 100
|
||||||
|
if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.isRadio) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scrobbled) {
|
||||||
|
try {
|
||||||
|
if (info.trackId) {
|
||||||
|
subsonic.scrobble(info.trackId, startTime)
|
||||||
|
}
|
||||||
|
setScrobbled(true)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Scrobbling error:', error)
|
||||||
|
// Continue without failing the player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startTime, scrobbled],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles track change events by resetting scrobbling state.
|
||||||
|
*/
|
||||||
|
const onAudioPlayTrackChange = useCallback(() => {
|
||||||
|
if (scrobbled) {
|
||||||
|
setScrobbled(false)
|
||||||
|
}
|
||||||
|
if (startTime !== null) {
|
||||||
|
setStartTime(null)
|
||||||
|
}
|
||||||
|
}, [scrobbled, startTime])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles audio ended events, resetting state and performing keepalive.
|
||||||
|
*
|
||||||
|
* @param {string} currentPlayId - ID of the current playing track.
|
||||||
|
* @param {Array} audioLists - List of audio tracks.
|
||||||
|
* @param {Object} info - Audio information.
|
||||||
|
*/
|
||||||
|
const onAudioEnded = useCallback(
|
||||||
|
(currentPlayId, audioLists, info) => {
|
||||||
|
setScrobbled(false)
|
||||||
|
setStartTime(null)
|
||||||
|
try {
|
||||||
|
dataProvider
|
||||||
|
.getOne('keepalive', { id: info.trackId })
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
.catch((e) => console.log('Keepalive error:', e))
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Keepalive error:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dataProvider],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the scrobbling state. Useful for manual resets or testing.
|
||||||
|
*/
|
||||||
|
const resetScrobbling = useCallback(() => {
|
||||||
|
setScrobbled(false)
|
||||||
|
setStartTime(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime,
|
||||||
|
setStartTime,
|
||||||
|
scrobbled,
|
||||||
|
onAudioProgress,
|
||||||
|
onAudioPlayTrackChange,
|
||||||
|
onAudioEnded,
|
||||||
|
resetScrobbling,
|
||||||
|
}
|
||||||
|
}
|
||||||
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal file
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react-hooks'
|
||||||
|
import { useScrobbling } from './useScrobbling'
|
||||||
|
import { describe, it, beforeEach, vi, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Mock subsonic module
|
||||||
|
vi.mock('../../subsonic', () => ({
|
||||||
|
default: {
|
||||||
|
scrobble: vi.fn(),
|
||||||
|
nowPlaying: vi.fn(),
|
||||||
|
},
|
||||||
|
scrobble: vi.fn(),
|
||||||
|
nowPlaying: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import the mocked module
|
||||||
|
import * as subsonic from '../../subsonic'
|
||||||
|
|
||||||
|
// Mock dataProvider
|
||||||
|
const mockDataProvider = {
|
||||||
|
getOne: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useScrobbling', () => {
|
||||||
|
const mockPlayerState = {
|
||||||
|
queue: [
|
||||||
|
{ uuid: '1', musicSrc: 'song1.mp3' },
|
||||||
|
{ uuid: '2', musicSrc: 'song2.mp3' },
|
||||||
|
],
|
||||||
|
current: { uuid: '1', trackId: 'track1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDispatch = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockDataProvider.getOne.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current.startTime).toBeNull()
|
||||||
|
expect(result.current.scrobbled).toBe(false)
|
||||||
|
expect(typeof result.current.onAudioProgress).toBe('function')
|
||||||
|
expect(typeof result.current.onAudioPlayTrackChange).toBe('function')
|
||||||
|
expect(typeof result.current.onAudioEnded).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle audio progress and scrobble when conditions are met', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockInfo = {
|
||||||
|
currentTime: 300, // 5 minutes
|
||||||
|
duration: 240, // 4 minutes
|
||||||
|
isRadio: false,
|
||||||
|
trackId: 'track1',
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onAudioProgress(mockInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should scrobble since progress > 50% and time > 4 minutes
|
||||||
|
expect(subsonic.default.scrobble).toHaveBeenCalledWith('track1', null)
|
||||||
|
expect(result.current.scrobbled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not scrobble radio streams', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockInfo = {
|
||||||
|
currentTime: 300,
|
||||||
|
duration: 240,
|
||||||
|
isRadio: true,
|
||||||
|
trackId: 'track1',
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onAudioProgress(mockInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(subsonic.scrobble).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset scrobbling state on track change', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
act(() => {
|
||||||
|
const mockInfo = {
|
||||||
|
currentTime: 300,
|
||||||
|
duration: 240,
|
||||||
|
isRadio: false,
|
||||||
|
trackId: 'track1',
|
||||||
|
}
|
||||||
|
result.current.onAudioProgress(mockInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scrobbled).toBe(true)
|
||||||
|
|
||||||
|
// Track change should reset
|
||||||
|
act(() => {
|
||||||
|
result.current.onAudioPlayTrackChange()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scrobbled).toBe(false)
|
||||||
|
expect(result.current.startTime).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle audio ended and perform keepalive', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockInfo = { trackId: 'track1' }
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onAudioEnded('playId', [], mockInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scrobbled).toBe(false)
|
||||||
|
expect(result.current.startTime).toBeNull()
|
||||||
|
expect(mockDataProvider.getOne).toHaveBeenCalledWith('keepalive', {
|
||||||
|
id: 'track1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle scrobbling errors gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
// const mockSubsonic = subsonic
|
||||||
|
subsonic.default.scrobble.mockImplementation(() => {
|
||||||
|
throw new Error('Scrobbling failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrobbling(mockPlayerState, mockDispatch, mockDataProvider),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockInfo = {
|
||||||
|
currentTime: 300,
|
||||||
|
duration: 240,
|
||||||
|
isRadio: false,
|
||||||
|
trackId: 'track1',
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onAudioProgress(mockInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Scrobbling error:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
expect(result.current.scrobbled).toBe(false) // Should not set to true on error
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user