Merge dab37f6a898fad83bda9111da9288d3674fbe959 into 385e75e9a978622ad647c38114e44d0b9095ca6b

This commit is contained in:
Deluan Quintão 2026-07-01 16:58:55 +02:00 committed by GitHub
commit 2b1cd79a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 421 additions and 55 deletions

View File

@ -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)

View File

@ -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

View File

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

View File

@ -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

View File

@ -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, ",")),

View File

@ -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),

View File

@ -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++

View File

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

View File

@ -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 {

View File

@ -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) {

View File

@ -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'

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

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

View File

@ -34,6 +34,7 @@ const defaultConfig = {
enableCoverAnimation: true,
enableNowPlaying: true,
playbackReportIntervalMs: 60000,
nowPlayingAdminOnly: false,
devShowArtistPage: true,
devUIShowConfig: true,
devNewEventStream: false,

View File

@ -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} />

View File

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

View File

@ -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]),

View File

@ -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') {

View File

@ -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(

View File

@ -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(