mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge dab37f6a898fad83bda9111da9288d3674fbe959 into 385e75e9a978622ad647c38114e44d0b9095ca6b
This commit is contained in:
commit
2b1cd79a9e
@ -95,7 +95,7 @@ type configOptions struct {
|
||||
UICoverArtSize int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
NowPlaying nowPlayingOptions `json:",omitzero"`
|
||||
UIPlaybackReportInterval time.Duration
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
@ -236,6 +236,11 @@ type jukeboxOptions struct {
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
type nowPlayingOptions struct {
|
||||
Enabled bool
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path Dir
|
||||
@ -332,6 +337,7 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
mapDeprecatedOption("EnableNowPlaying", "NowPlaying.Enabled")
|
||||
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
@ -458,6 +464,7 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
logDeprecatedOptions("EnableNowPlaying", "NowPlaying.Enabled")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
@ -789,7 +796,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("nowplaying.enabled", true)
|
||||
viper.SetDefault("nowplaying.adminonly", false)
|
||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
|
||||
@ -198,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
||||
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableNowPlaying = conf.Server.NowPlaying.Enabled
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
|
||||
@ -132,7 +132,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
prSignal: make(chan struct{}, 1),
|
||||
prWorkerDone: make(chan struct{}),
|
||||
}
|
||||
enableNowPlaying := conf.Server.EnableNowPlaying
|
||||
enableNowPlaying := conf.Server.NowPlaying.Enabled
|
||||
m.OnExpiration(func(_ string, info PlaybackSession) {
|
||||
log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
|
||||
info.State, "username", info.Username, "userId", info.UserId)
|
||||
@ -367,7 +367,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.playMap.Remove(clientId)
|
||||
}
|
||||
|
||||
if conf.Server.EnableNowPlaying {
|
||||
if conf.Server.NowPlaying.Enabled {
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
|
||||
|
||||
@ -148,7 +148,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does not send event when disabled", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
conf.Server.NowPlaying.Enabled = false
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
||||
@ -455,8 +455,8 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does NOT broadcast when EnableNowPlaying is false", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
It("does NOT broadcast when NowPlaying is disabled", func() {
|
||||
conf.Server.NowPlaying.Enabled = false
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.builtinScrobblers["fake"] = fake
|
||||
|
||||
|
||||
@ -57,7 +57,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||
"enableNowPlaying": conf.Server.NowPlaying.Enabled,
|
||||
"nowPlayingAdminOnly": conf.Server.NowPlaying.AdminOnly,
|
||||
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
||||
|
||||
@ -88,7 +88,8 @@ var _ = Describe("serveIndex", func() {
|
||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
||||
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.NowPlaying.Enabled = true }, "enableNowPlaying", true),
|
||||
Entry("nowPlayingAdminOnly", func() { conf.Server.NowPlaying.AdminOnly = true }, "nowPlayingAdminOnly", true),
|
||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
||||
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
|
||||
|
||||
@ -3,9 +3,11 @@ package subsonic
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -203,14 +205,33 @@ func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
||||
func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
|
||||
// When restricted to admins, non-admin users get an empty list
|
||||
if conf.Server.NowPlaying.AdminOnly && !getUser(ctx).IsAdmin {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
npInfo, err := api.scrobbler.GetNowPlaying(ctx)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving now playing list", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
// Optionally restrict to specific libraries via the standard Subsonic musicFolderId param.
|
||||
// When absent, all entries are returned, per the getNowPlaying spec.
|
||||
requestedFolderIds, _ := req.Params(r).Ints("musicFolderId")
|
||||
if len(requestedFolderIds) > 0 {
|
||||
folderIds, ferr := selectedMusicFolderIds(r, false)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
npInfo = slice.Filter(npInfo, func(np scrobbler.PlaybackSession) bool {
|
||||
return slices.Contains(folderIds, np.MediaFile.LibraryID)
|
||||
})
|
||||
}
|
||||
|
||||
var i int32
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry {
|
||||
i++
|
||||
|
||||
@ -4,8 +4,12 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -539,4 +543,123 @@ var _ = Describe("Album Lists", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
var mockPlayTracker *fakePlayTracker
|
||||
var user model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlayTracker = &fakePlayTracker{}
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return what all users are playing, regardless of the requesting user's libraries", func() {
|
||||
// The Subsonic getNowPlaying contract returns activity from all users;
|
||||
// it does not filter by the requesting user's library access.
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.PlaybackSession{
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "1", Title: "Track 1", LibraryID: 1},
|
||||
Start: time.Now(),
|
||||
Username: "user1",
|
||||
PlayerId: "player1",
|
||||
PlayerName: "Player 1",
|
||||
},
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "2", Title: "Track 2", LibraryID: 3}, // Library the requesting user can't access
|
||||
Start: time.Now(),
|
||||
Username: "user2",
|
||||
PlayerId: "player2",
|
||||
PlayerName: "Player 2",
|
||||
},
|
||||
}
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil, nil, nil, nil)
|
||||
ctx := request.WithUser(context.Background(), user)
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetNowPlaying(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NowPlaying.Entry).To(HaveLen(2))
|
||||
Expect(resp.NowPlaying.Entry[0].Title).To(Equal("Track 1"))
|
||||
Expect(resp.NowPlaying.Entry[1].Title).To(Equal("Track 2"))
|
||||
})
|
||||
|
||||
It("should filter entries by the musicFolderId parameter when provided", func() {
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.PlaybackSession{
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "1", Title: "Track 1", LibraryID: 1},
|
||||
Start: time.Now(),
|
||||
Username: "user1",
|
||||
PlayerId: "player1",
|
||||
PlayerName: "Player 1",
|
||||
},
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "2", Title: "Track 2", LibraryID: 2},
|
||||
Start: time.Now(),
|
||||
Username: "user2",
|
||||
PlayerId: "player2",
|
||||
PlayerName: "Player 2",
|
||||
},
|
||||
}
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil, nil, nil, nil)
|
||||
ctx := request.WithUser(context.Background(), user)
|
||||
r := newGetRequest("musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetNowPlaying(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NowPlaying.Entry).To(HaveLen(1))
|
||||
Expect(resp.NowPlaying.Entry[0].Title).To(Equal("Track 1"))
|
||||
})
|
||||
|
||||
Context("when NowPlaying.AdminOnly is enabled", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.NowPlaying.AdminOnly = true
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.PlaybackSession{
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "1", Title: "Track 1", LibraryID: 1},
|
||||
Start: time.Now(),
|
||||
Username: "user1",
|
||||
PlayerId: "player1",
|
||||
PlayerName: "Player 1",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return an empty list to non-admin users", func() {
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil, nil, nil, nil)
|
||||
ctx := request.WithUser(context.Background(), user) // user is not admin
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetNowPlaying(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NowPlaying.Entry).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return entries to admin users", func() {
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil, nil, nil, nil)
|
||||
admin := user
|
||||
admin.IsAdmin = true
|
||||
ctx := request.WithUser(context.Background(), admin)
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetNowPlaying(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NowPlaying.Entry).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -190,11 +190,12 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
type fakePlayTracker struct {
|
||||
Submissions []scrobbler.Submission
|
||||
ReportedPlayback []scrobbler.ReportPlaybackParams
|
||||
NowPlayingData []scrobbler.PlaybackSession
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) {
|
||||
return nil, f.Error
|
||||
return f.NowPlayingData, f.Error
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Submission) error {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
|
||||
import { useTranslate, useRefresh } from 'react-admin'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
@ -15,8 +15,8 @@ import {
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
|
||||
import { setSelectedLibraries, setUserLibraries } from '../actions'
|
||||
import { useRefreshOnEvents } from './useRefreshOnEvents'
|
||||
import { setSelectedLibraries } from '../actions'
|
||||
import { useUserLibraries } from './useUserLibraries'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
@ -70,7 +70,6 @@ const useStyles = makeStyles((theme) => ({
|
||||
const LibrarySelector = () => {
|
||||
const classes = useStyles()
|
||||
const dispatch = useDispatch()
|
||||
const dataProvider = useDataProvider()
|
||||
const translate = useTranslate()
|
||||
const refresh = useRefresh()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
@ -80,34 +79,9 @@ const LibrarySelector = () => {
|
||||
(state) => state.library,
|
||||
)
|
||||
|
||||
// Load user's libraries when component mounts
|
||||
const loadUserLibraries = useCallback(async () => {
|
||||
const userId = localStorage.getItem('userId')
|
||||
if (userId) {
|
||||
try {
|
||||
const { data } = await dataProvider.getOne('user', { id: userId })
|
||||
const libraries = data.libraries || []
|
||||
dispatch(setUserLibraries(libraries))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Could not load user libraries (this may be expected for non-admin users):',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [dataProvider, dispatch])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadUserLibraries()
|
||||
}, [loadUserLibraries])
|
||||
|
||||
// Reload user libraries when library changes occur
|
||||
useRefreshOnEvents({
|
||||
events: ['library', 'user'],
|
||||
onRefresh: loadUserLibraries,
|
||||
})
|
||||
// Keep the user's libraries loaded (also done at the Layout level so the data
|
||||
// is available even when this selector isn't rendered).
|
||||
useUserLibraries()
|
||||
|
||||
// Don't render if user has no libraries or only has one library
|
||||
if (!userLibraries.length || userLibraries.length === 1) {
|
||||
|
||||
@ -28,6 +28,7 @@ export * from './useGetHandleArtistClick'
|
||||
export * from './useInterval'
|
||||
export * from './useResourceRefresh'
|
||||
export * from './useRefreshOnEvents'
|
||||
export * from './useUserLibraries'
|
||||
export * from './useToggleLove'
|
||||
export * from './useTraceUpdate'
|
||||
export * from './Writable'
|
||||
|
||||
40
ui/src/common/useUserLibraries.js
Normal file
40
ui/src/common/useUserLibraries.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useDataProvider } from 'react-admin'
|
||||
import { setUserLibraries } from '../actions'
|
||||
import { useRefreshOnEvents } from './useRefreshOnEvents'
|
||||
|
||||
/**
|
||||
* Loads the current user's accessible libraries into the Redux store and keeps
|
||||
* them refreshed when library/user events occur. Mount this once high in the
|
||||
* tree (e.g. the Layout) so consumers like useSelectedLibraries always have the
|
||||
* data available, regardless of whether the sidebar/LibrarySelector is open.
|
||||
*/
|
||||
export const useUserLibraries = () => {
|
||||
const dispatch = useDispatch()
|
||||
const dataProvider = useDataProvider()
|
||||
|
||||
const loadUserLibraries = useCallback(async () => {
|
||||
const userId = localStorage.getItem('userId')
|
||||
if (!userId) return
|
||||
try {
|
||||
const { data } = await dataProvider.getOne('user', { id: userId })
|
||||
dispatch(setUserLibraries(data.libraries || []))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Could not load user libraries (this may be expected for non-admin users):',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}, [dataProvider, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
loadUserLibraries()
|
||||
}, [loadUserLibraries])
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library', 'user'],
|
||||
onRefresh: loadUserLibraries,
|
||||
})
|
||||
}
|
||||
65
ui/src/common/useUserLibraries.test.js
Normal file
65
ui/src/common/useUserLibraries.test.js
Normal file
@ -0,0 +1,65 @@
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { useUserLibraries } from './useUserLibraries'
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const mockDispatch = vi.fn()
|
||||
const mockGetOne = vi.fn()
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => mockDispatch,
|
||||
}))
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useDataProvider: () => ({ getOne: mockGetOne }),
|
||||
}))
|
||||
|
||||
vi.mock('./useRefreshOnEvents', () => ({
|
||||
useRefreshOnEvents: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useUserLibraries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('loads the user libraries and dispatches them on mount', async () => {
|
||||
localStorage.setItem('userId', 'u-1')
|
||||
const libraries = [{ id: 1 }, { id: 2 }]
|
||||
mockGetOne.mockResolvedValue({ data: { libraries } })
|
||||
|
||||
renderHook(() => useUserLibraries())
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOne).toHaveBeenCalledWith('user', { id: 'u-1' })
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: libraries }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fetch when there is no userId', () => {
|
||||
renderHook(() => useUserLibraries())
|
||||
|
||||
expect(mockGetOne).not.toHaveBeenCalled()
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles a failed fetch without dispatching', async () => {
|
||||
localStorage.setItem('userId', 'u-1')
|
||||
mockGetOne.mockRejectedValue(new Error('forbidden'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
renderHook(() => useUserLibraries())
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOne).toHaveBeenCalled()
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -34,6 +34,7 @@ const defaultConfig = {
|
||||
enableCoverAnimation: true,
|
||||
enableNowPlaying: true,
|
||||
playbackReportIntervalMs: 60000,
|
||||
nowPlayingAdminOnly: false,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
devNewEventStream: false,
|
||||
|
||||
@ -118,11 +118,13 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const canViewNowPlaying =
|
||||
config.enableNowPlaying &&
|
||||
(!config.nowPlayingAdminOnly || permissions === 'admin')
|
||||
|
||||
return (
|
||||
<>
|
||||
{config.devActivityPanel &&
|
||||
permissions === 'admin' &&
|
||||
config.enableNowPlaying && <NowPlayingPanel />}
|
||||
{config.devActivityPanel && canViewNowPlaying && <NowPlayingPanel />}
|
||||
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
|
||||
<UserMenu {...rest}>
|
||||
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
||||
|
||||
@ -8,11 +8,12 @@ import AppBar from './AppBar'
|
||||
import config from '../config'
|
||||
|
||||
let store
|
||||
let mockPermissions = 'admin'
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>,
|
||||
useTranslate: () => (x) => x,
|
||||
usePermissions: () => ({ permissions: 'admin' }),
|
||||
usePermissions: () => ({ permissions: mockPermissions }),
|
||||
getResources: () => [],
|
||||
}))
|
||||
|
||||
@ -39,6 +40,8 @@ describe('<AppBar />', () => {
|
||||
beforeEach(() => {
|
||||
config.devActivityPanel = true
|
||||
config.enableNowPlaying = true
|
||||
config.nowPlayingAdminOnly = true
|
||||
mockPermissions = 'admin'
|
||||
store = createStore(combineReducers({ activity: activityReducer }), {
|
||||
activity: { nowPlayingCount: 0 },
|
||||
})
|
||||
@ -62,4 +65,77 @@ describe('<AppBar />', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows NowPlayingPanel to all users when adminOnly is false', () => {
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('admin-only mode', () => {
|
||||
beforeEach(() => {
|
||||
config.nowPlayingAdminOnly = true
|
||||
})
|
||||
|
||||
it('shows NowPlayingPanel to admin users', () => {
|
||||
mockPermissions = 'admin'
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides NowPlayingPanel from non-admin users', () => {
|
||||
mockPermissions = 'user'
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-admin users', () => {
|
||||
beforeEach(() => {
|
||||
mockPermissions = 'user'
|
||||
})
|
||||
|
||||
it('cannot see NowPlayingPanel when adminOnly is true', () => {
|
||||
config.nowPlayingAdminOnly = true
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('can see NowPlayingPanel when adminOnly is false', () => {
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cannot see NowPlayingPanel when feature is disabled', () => {
|
||||
config.enableNowPlaying = false
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,7 +7,7 @@ import Menu from './Menu'
|
||||
import AppBar from './AppBar'
|
||||
import Notification from './Notification'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import { useSearchRefocus } from '../common'
|
||||
import { useSearchRefocus, useUserLibraries } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
|
||||
@ -19,6 +19,7 @@ const Layout = (props) => {
|
||||
const classes = useStyles({ addPadding: queue.length > 0 })
|
||||
const dispatch = useDispatch()
|
||||
useSearchRefocus()
|
||||
useUserLibraries()
|
||||
|
||||
const keyHandlers = {
|
||||
TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { FaRegCirclePlay, FaPause } from 'react-icons/fa6'
|
||||
import subsonic from '../subsonic'
|
||||
import { useInterval } from '../common'
|
||||
import { useSelectedLibraries } from '../common/useLibrarySelection'
|
||||
import { nowPlayingCountSync } from '../actions'
|
||||
import { formatDuration } from '../utils'
|
||||
import config from '../config'
|
||||
@ -370,6 +371,9 @@ const NowPlayingPanel = () => {
|
||||
const serverUp = useSelector(
|
||||
(state) => !!state.activity.serverStart.startTime,
|
||||
)
|
||||
// Limit results to libraries the user can access (explicit picker selection,
|
||||
// or all accessible libraries when nothing is narrowed).
|
||||
const libraryIds = useSelectedLibraries()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const theme = useTheme()
|
||||
@ -406,7 +410,7 @@ const NowPlayingPanel = () => {
|
||||
const doFetchRef = useRef()
|
||||
doFetchRef.current = () =>
|
||||
subsonic
|
||||
.getNowPlaying()
|
||||
.getNowPlaying(libraryIds)
|
||||
.then((resp) => resp.json['subsonic-response'])
|
||||
.then((data) => {
|
||||
if (data.status === 'ok') {
|
||||
|
||||
@ -55,7 +55,7 @@ vi.mock('@material-ui/core/styles/useTheme', () => ({
|
||||
}))
|
||||
|
||||
describe('<NowPlayingPanel />', () => {
|
||||
const createMockStore = (overrides = {}) => {
|
||||
const createMockStore = (overrides = {}, libraryOverrides = {}) => {
|
||||
const defaultState = {
|
||||
activity: {
|
||||
nowPlayingCount: 1,
|
||||
@ -63,9 +63,17 @@ describe('<NowPlayingPanel />', () => {
|
||||
streamReconnected: 0,
|
||||
...overrides,
|
||||
},
|
||||
library: {
|
||||
userLibraries: [],
|
||||
selectedLibraries: [],
|
||||
...libraryOverrides,
|
||||
},
|
||||
}
|
||||
return createStore(
|
||||
combineReducers({ activity: activityReducer }),
|
||||
combineReducers({
|
||||
activity: activityReducer,
|
||||
library: (state = defaultState.library) => state,
|
||||
}),
|
||||
defaultState,
|
||||
)
|
||||
}
|
||||
@ -123,6 +131,38 @@ describe('<NowPlayingPanel />', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requests all accessible libraries when no explicit selection', async () => {
|
||||
const store = createMockStore(
|
||||
{},
|
||||
{ userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [] },
|
||||
)
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<NowPlayingPanel />
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(subsonic.getNowPlaying).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('requests only the selected libraries when narrowed', async () => {
|
||||
const store = createMockStore(
|
||||
{},
|
||||
{ userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [2] },
|
||||
)
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<NowPlayingPanel />
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(subsonic.getNowPlaying).toHaveBeenCalledWith([2])
|
||||
})
|
||||
|
||||
it('displays player name after username', async () => {
|
||||
const store = createMockStore()
|
||||
render(
|
||||
|
||||
@ -70,7 +70,14 @@ const startScan = (options) => httpClient(url('startScan', null, options))
|
||||
|
||||
const getScanStatus = () => httpClient(url('getScanStatus'))
|
||||
|
||||
const getNowPlaying = () => httpClient(url('getNowPlaying'))
|
||||
const getNowPlaying = (musicFolderId) =>
|
||||
httpClient(
|
||||
url(
|
||||
'getNowPlaying',
|
||||
null,
|
||||
musicFolderId?.length ? { musicFolderId } : undefined,
|
||||
),
|
||||
)
|
||||
|
||||
const getAvatarUrl = (username, size) =>
|
||||
baseUrl(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user