Compare commits

...

18 Commits

Author SHA1 Message Date
Xavier Araque
3a1b6d8465
Merge 59427e29ab4567b19c8278d4b8820c0287a033a8 into 67c4e249570c1928f3559a694427a6ce34adda67 2025-11-22 14:56:51 +01:00
Deluan
67c4e24957 fix(scanner): defer artwork PreCache calls until after transaction commits
The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation.

Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images.

The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:27:25 -05:00
Deluan Quintão
255ed1f8e2
feat(deezer): Add artist bio, top tracks, related artists and language support (#4720)
* feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(deezer): add language support for Deezer API client

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deezer): Use GraphQL API for translated biographies

The previous implementation scraped the __DZR_APP_STATE__ from HTML,
which only contained English content. The actual biography displayed
on Deezer's website comes from their GraphQL API at pipe.deezer.com,
which properly respects the Accept-Language header and returns
translated content.

This change:
- Switches from HTML scraping to the GraphQL API
- Uses Accept-Language header instead of URL path for language
- Updates tests to match the new implementation
- Removes unused HTML fixture file

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(deezer): move JWT token handling to a separate file for better organization

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(deezer): enhance JWT token handling with expiration validation

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(deezer): change log level for unknown agent warnings from Warn to Debug

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:09:24 -05:00
Deluan
152f57e642 chore(deps): update golangci-lint version to v2.6.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:54 -05:00
Deluan
5c16622501 chore(makefile): update golangci-lint version to v2.6.2
See comment 0c71842b12 (commitcomment-170969373)

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:40 -05:00
Deluan
36fa869329 feat(scanner): improve error messages for cleanup operations in annotations, bookmarks, and tags
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 09:27:42 -05:00
Xavier Araque
59427e29ab
Merge branch 'master' into feat/separation-of-responsibilities-player 2025-11-07 17:18:47 +01:00
Xavier Araque
3d2f7148e7 fix: critical AudioContext and setStartTime, prettier 2025-11-07 17:13:45 +01:00
Xavier Araque
f0c0e804fa fix: critical AudioContext and setStartTime 2025-11-07 17:11:47 +01:00
Xavier Araque
4bbcbc17f1 fix: test 2025-11-07 16:48:45 +01:00
Xavier Araque
6a9ccb309c fix: test 2025-11-07 16:46:30 +01:00
Xavier Araque
efeaa80de9 fix: test reducer 2025-11-07 16:13:27 +01:00
Xavier Araque
f54229ea50 fix: test 2025-11-07 16:01:28 +01:00
Xavier Araque
a82a03feda fix: test 2025-11-07 16:00:12 +01:00
Xavier Araque
d3c2beabd8 Player.jsx Component - Comprehensive Architecture Improvement, fix vitest 2025-11-07 15:48:08 +01:00
Xavier Araque
e3534fa56b Player.jsx Component - Comprehensive Architecture Improvement, fix test 2025-11-07 15:31:48 +01:00
Xavier Araque
b342035884 Player.jsx Component - Comprehensive Architecture Improvement 2025-11-07 15:25:00 +01:00
Xavier Araque
b0e2457e61 Player.jsx Component - Comprehensive Architecture Improvement 2025-11-07 15:19:32 +01:00
29 changed files with 2164 additions and 207 deletions

View File

@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
GOLANGCI_LINT_VERSION ?= v2.5.0
GOLANGCI_LINT_VERSION ?= v2.6.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@ -176,7 +176,8 @@ type spotifyOptions struct {
}
type deezerOptions struct {
Enabled bool
Enabled bool
Language string
}
type listenBrainzOptions struct {
@ -566,6 +567,7 @@ func setViperDefaults() {
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")

View File

@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Warn("Unknown agent ignored", "name", name)
log.Debug("Unknown agent ignored", "name", name)
}
}
return validAgents

View File

@ -1,6 +1,7 @@
package deezer
import (
bytes "bytes"
"context"
"encoding/json"
"errors"
@ -9,11 +10,14 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
@ -25,10 +29,15 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
language string
jwt jwtToken
}
func newClient(hc httpDoer) *client {
return &client{hc}
func newClient(hc httpDoer, language string) *client {
return &client{
httpDoer: hc,
language: language,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error {
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
var results RelatedArtists
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results TopTracks
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
}
query := map[string]any{
"operationName": "ArtistBio",
"variables": map[string]any{
"artistId": strconv.Itoa(artistID),
},
"query": `query ArtistBio($artistId: String!) {
artist(artistId: $artistId) {
bio {
full
}
}
}`,
}
body, err := json.Marshal(query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type graphQLResponse struct {
Data struct {
Artist struct {
Bio struct {
Full string `json:"full"`
} `json:"bio"`
} `json:"artist"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
}
}
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
var errs []error
for m := range result.Errors {
errs = append(errs, errors.New(result.Errors[m].Message))
}
err := errors.Join(errs...)
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
}
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {
bio = strings.ReplaceAll(bio, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}

View 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
}

View 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)
}

View File

@ -2,10 +2,11 @@ package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -17,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
client = newClient(httpClient, "en")
})
Describe("ArtistImages", func() {
@ -26,7 +27,7 @@ var _ = Describe("client", func() {
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
@ -39,10 +40,136 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the configured language", func() {
client = newClient(httpClient, "fr")
// Mock JWT token for the new client instance with a valid JWT
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
Expect(authHeader).To(HavePrefix("Bearer "))
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
})
It("handles GraphQL errors", func() {
errorResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
},
"errors": [
{
"message": "Artist not found"
},
{
"message": "Invalid artist ID"
}
]
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
})
It("handles empty biography", func() {
emptyBioResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
}
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(MatchError("deezer: biography not found"))
})
It("handles JWT token fetch failure", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
It("handles JWT token that expires too soon", func() {
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
expiredJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
})
})
type fakeHttpClient struct {

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return &artists[0], err
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return nil, err
}
related, err := s.client.getRelatedArtists(ctx, artist.ID)
if err != nil {
return nil, err
}
res := slice.Map(related, func(r Artist) agents.Artist {
return agents.Artist{
Name: r.Name,
}
})
if len(res) > limit {
res = res[:limit]
}
return res, nil
}
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
artist, err := s.searchArtist(ctx, artistName)
if err != nil {
return nil, err
}
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
if err != nil {
return nil, err
}
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
}
})
return res, nil
}
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return "", err
}
return s.client.getArtistBio(ctx, artist.ID)
}
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {

View File

@ -29,3 +29,38 @@ type Error struct {
Code int `json:"code"`
} `json:"error"`
}
type RelatedArtists struct {
Data []Artist `json:"data"`
Total int `json:"total"`
}
type TopTracks struct {
Data []Track `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Track struct {
ID int `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
Rank int `json:"rank"`
Preview string `json:"preview"`
Artist Artist `json:"artist"`
Album Album `json:"album"`
Contributors []Artist `json:"contributors"`
}
type Album struct {
ID int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverSmall string `json:"cover_small"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXl string `json:"cover_xl"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}

View File

@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
Describe("Related Artists", func() {
It("parses the related artists response correctly", func() {
var resp RelatedArtists
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(20))
justice := resp.Data[0]
Expect(justice.Name).To(Equal("Justice"))
Expect(justice.ID).To(Equal(6404))
})
})
Describe("Top Tracks", func() {
It("parses the top tracks response correctly", func() {
var resp TopTracks
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(5))
track := resp.Data[0]
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(track.ID).To(Equal(67238732))
Expect(track.Album.Title).To(Equal("Random Access Memories"))
})
})
})

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.25.4
go 1.25
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d

View File

@ -119,7 +119,7 @@ func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error cleaning up annotations: %w", err)
return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)

View File

@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error cleaning up bookmarks: %w", err)
return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
}
return nil
}

View File

@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
`)
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error purging unused tags: %w", err)
return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
}
return err
}

View File

@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
// Collect artwork IDs to pre-cache after the transaction commits
var artworkIDs []model.ArtworkID
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
entry.job.cw.PreCache(entry.artists[i].CoverArtID())
artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID())
}
}
@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
entry.job.cw.PreCache(entry.albums[i].CoverArtID())
artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID())
}
}
@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
// Pre-cache artwork after the transaction commits successfully
if err == nil {
for _, artID := range artworkIDs {
entry.job.cw.PreCache(artID)
}
}
return entry, err
}

9
tests/fixtures/deezer.artist.bio.json vendored Normal file
View 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>"
}
}
}
}

File diff suppressed because one or more lines are too long

1
tests/fixtures/deezer.artist.top.json vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import React, { useCallback, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { useMediaQuery } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/core/styles'
import {
@ -16,32 +16,28 @@ import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import useStyle from './styles'
import AudioTitle from './AudioTitle'
import {
clearQueue,
currentPlaying,
setPlayMode,
setVolume,
syncQueue,
} from '../actions'
import PlayerToolbar from './PlayerToolbar'
import { sendNotification } from '../utils'
import subsonic from '../subsonic'
import locale from './locale'
import { keyMap } from '../hotkeys'
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 theme = useCurrentTheme()
const translate = useTranslate()
const playerTheme = theme.player?.theme || 'dark'
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 isMobilePlayer =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
@ -49,6 +45,39 @@ const Player = () => {
)
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 isRadio = playerState.current?.isRadio || false
const classes = useStyle({
@ -56,44 +85,6 @@ const Player = () => {
visible,
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(
() => ({
@ -129,140 +120,84 @@ const Player = () => {
locale: locale(translate),
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 current = playerState.current || {}
return {
...defaultOptions,
audioLists: playerState.queue.map((item) => item),
audioLists,
playIndex: playerState.playIndex,
autoPlay: playerState.clear || playerState.playIndex === 0,
clearPriorAudioLists: playerState.clear,
extendsContent: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
<PlayerToolbar
id={currentTrack.trackId}
isRadio={currentTrack.isRadio}
/>
),
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(
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
[dispatch],
)
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],
(_, audioLists, audioInfo) => dispatchSyncQueue(audioInfo, audioLists),
[dispatchSyncQueue],
)
const onAudioVolumeChange = useCallback(
// sqrt to compensate for the logarithmic volume
(volume) => dispatch(setVolume(Math.sqrt(volume))),
[dispatch],
(volume) => dispatchSetVolume(volume),
[dispatchSetVolume],
)
const onAudioPlay = useCallback(
const handleAudioPlay = useCallback(
(info) => {
// Do this to start the context; on chrome-based browsers, the context
// will start paused since it is created prior to user interaction
if (context && context.state !== 'running') {
context.resume()
}
dispatch(currentPlaying(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)
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,
)
}
}
onAudioPlay(
context,
info,
(info) => dispatchCurrentPlaying(info),
showNotifications,
sendNotification,
startTime,
setStartTime,
resetPreloading,
config,
ReactGA,
)
},
[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(
(info) => dispatch(currentPlaying(info)),
[dispatch],
)
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],
(info) => dispatchCurrentPlaying(info),
[dispatchCurrentPlaying],
)
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
@ -273,10 +208,10 @@ const Player = () => {
const onBeforeDestroy = useCallback(() => {
return new Promise((resolve, reject) => {
dispatch(clearQueue())
dispatchClearQueue()
reject()
})
}, [dispatch])
}, [dispatchClearQueue])
if (!visible) {
document.title = 'Navidrome'
@ -287,30 +222,32 @@ const Player = () => {
[audioInstance, playerState],
)
useEffect(() => {
if (isMobilePlayer && audioInstance) {
audioInstance.volume = 1
}
}, [isMobilePlayer, audioInstance])
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
<div role="region" aria-label="Audio Player" aria-live="polite">
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={handleAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onPlayModeChange={dispatchSetPlayMode}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
aria-label="Music Player"
/>
<GlobalHotKeys
handlers={handlers}
keyMap={keyMap}
allowChanges
aria-hidden="true"
/>
</div>
</ThemeProvider>
)
}

View 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()
})
})
})

View 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,
}
}

View 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,
}
}

View 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))
})
})

View 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,
}
}

View 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()
})
})

View 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,
}
}

View 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()
})
})

View 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,
}
}

View 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()
})
})