Compare commits

...

6 commits

Author SHA1 Message Date
Deluan
dab37f6a89 fix(ui): load user libraries on app init so NowPlaying filters correctly
The NowPlaying panel filters entries by the user's accessible libraries via
useSelectedLibraries(). That selector reads userLibraries from the store, but
the data was only fetched by LibrarySelector, which is rendered solely when the
sidebar is open. On first load with the sidebar closed, userLibraries was empty,
so getNowPlaying was called without a musicFolderId and the server returned
entries from all libraries until a later refresh.

Extract the loading logic into a useUserLibraries hook and call it from Layout,
so the libraries are always loaded regardless of sidebar state. LibrarySelector
now reuses the same hook instead of duplicating the fetch.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-20 00:11:54 -04:00
Deluan
736e339080 feat: filter NowPlaying by musicFolderId so the UI hides inaccessible entries
Adds support for the standard Subsonic musicFolderId parameter on the
getNowPlaying endpoint. When provided, results are restricted to those
libraries (validated against the user's access); when absent, all entries are
returned, preserving the spec behavior for third-party clients.

The web UI now passes the user's accessible libraries (the active picker
selection when set, otherwise all of the user's libraries), so the NowPlaying
panel no longer shows entries the user cannot open — which previously rendered
with broken cover art and dead album/artist links.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-19 22:43:30 -04:00
Deluan
df74ecb1ec refactor(ui): extract NowPlaying visibility check into a named variable
Pulls the compound "enabled && (not admin-only or is admin)" condition out
of the JSX into a canViewNowPlaying variable, so the AppBar render reads as
intent rather than a multi-operator boolean. No behavior change.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-19 22:24:49 -04:00
Deluan
4298cec37d feat: enforce NowPlaying.AdminOnly on the getNowPlaying endpoint
When NowPlaying.AdminOnly is enabled, non-admin users now receive an empty
list from the Subsonic getNowPlaying endpoint, mirroring how Jukebox.AdminOnly
is enforced server-side. Previously this restriction was only applied in the
web UI and could be bypassed by calling the endpoint directly.

The endpoint otherwise keeps the standard Subsonic behavior of returning what
all users are currently playing, without filtering by the requesting user's
library access.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-19 22:24:49 -04:00
Deluan
6af2b1d777 test: add comprehensive non-admin user test cases for NowPlaying visibility
Enhances test coverage by making the usePermissions mock dynamic and adding test cases that verify:
- Admin users can see NowPlayingPanel when adminOnly is true
- Non-admin users cannot see NowPlayingPanel when adminOnly is true
- Non-admin users can see NowPlayingPanel when adminOnly is false
- Non-admin users cannot see NowPlayingPanel when feature is disabled

This ensures the admin-only permission check works correctly for all user types.
2026-06-19 22:08:27 -04:00
Deluan
08e632d918 feat: add configurable visibility control for NowPlaying feature
Replaces the boolean EnableNowPlaying option with a more flexible NowPlaying configuration structure containing Enabled and AdminOnly flags. This allows three visibility modes: disabled, admin-only, and all users.

The new configuration uses nowplayingOptions struct similar to jukeboxOptions, with the following defaults:
- NowPlaying.Enabled: true (feature enabled)
- NowPlaying.AdminOnly: false (visible to all users)

The old EnableNowPlaying option is deprecated and automatically migrated to NowPlaying.Enabled with a warning message.

Frontend changes update the AppBar component to conditionally render NowPlayingPanel based on both the enabled state and the admin-only permission check. Server-side enforcement of the AdminOnly setting is added in a follow-up commit.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-19 22:08:27 -04:00
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(