diff --git a/conf/configuration.go b/conf/configuration.go
index 665a7992f..bd4f42aba 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -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)
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index bcd0343c2..40ff96600 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -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
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 860a80bce..5ff26d6d7 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -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()})
}
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index b5a478c2a..16e057bd7 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -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
diff --git a/server/serve_index.go b/server/serve_index.go
index 13fa4a9ce..b2aa25d9e 100644
--- a/server/serve_index.go
+++ b/server/serve_index.go
@@ -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, ",")),
diff --git a/server/serve_index_test.go b/server/serve_index_test.go
index 78f3873b8..1ecdb9cff 100644
--- a/server/serve_index_test.go
+++ b/server/serve_index_test.go
@@ -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),
diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go
index 24bbca960..5cc319cef 100644
--- a/server/subsonic/album_lists.go
+++ b/server/subsonic/album_lists.go
@@ -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++
diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go
index 220376b15..efd1362da 100644
--- a/server/subsonic/album_lists_test.go
+++ b/server/subsonic/album_lists_test.go
@@ -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))
+ })
+ })
+ })
})
diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go
index 487335d1a..12e485989 100644
--- a/server/subsonic/media_annotation_test.go
+++ b/server/subsonic/media_annotation_test.go
@@ -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 {
diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx
index 1e89d3ec6..170211c7f 100644
--- a/ui/src/common/LibrarySelector.jsx
+++ b/ui/src/common/LibrarySelector.jsx
@@ -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) {
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 362a0ced3..6959ed41b 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -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'
diff --git a/ui/src/common/useUserLibraries.js b/ui/src/common/useUserLibraries.js
new file mode 100644
index 000000000..7660ef8c4
--- /dev/null
+++ b/ui/src/common/useUserLibraries.js
@@ -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,
+ })
+}
diff --git a/ui/src/common/useUserLibraries.test.js b/ui/src/common/useUserLibraries.test.js
new file mode 100644
index 000000000..48035e745
--- /dev/null
+++ b/ui/src/common/useUserLibraries.test.js
@@ -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()
+ })
+})
diff --git a/ui/src/config.js b/ui/src/config.js
index 39f0cd467..2643c3a37 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -34,6 +34,7 @@ const defaultConfig = {
enableCoverAnimation: true,
enableNowPlaying: true,
playbackReportIntervalMs: 60000,
+ nowPlayingAdminOnly: false,
devShowArtistPage: true,
devUIShowConfig: true,
devNewEventStream: false,
diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx
index 561701dce..eaebad94e 100644
--- a/ui/src/layout/AppBar.jsx
+++ b/ui/src/layout/AppBar.jsx
@@ -118,11 +118,13 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
)
}
+ const canViewNowPlaying =
+ config.enableNowPlaying &&
+ (!config.nowPlayingAdminOnly || permissions === 'admin')
+
return (
<>
- {config.devActivityPanel &&
- permissions === 'admin' &&
- config.enableNowPlaying && }
+ {config.devActivityPanel && canViewNowPlaying && }
{config.devActivityPanel && permissions === 'admin' && }
diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx
index f39dd75cb..d3bae0635 100644
--- a/ui/src/layout/AppBar.test.jsx
+++ b/ui/src/layout/AppBar.test.jsx
@@ -8,11 +8,12 @@ import AppBar from './AppBar'
import config from '../config'
let store
+let mockPermissions = 'admin'
vi.mock('react-admin', () => ({
AppBar: ({ userMenu }) => {userMenu}
,
useTranslate: () => (x) => x,
- usePermissions: () => ({ permissions: 'admin' }),
+ usePermissions: () => ({ permissions: mockPermissions }),
getResources: () => [],
}))
@@ -39,6 +40,8 @@ describe('', () => {
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('', () => {
)
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
})
+
+ it('shows NowPlayingPanel to all users when adminOnly is false', () => {
+ config.nowPlayingAdminOnly = false
+ render(
+
+
+ ,
+ )
+ expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
+ })
+
+ describe('admin-only mode', () => {
+ beforeEach(() => {
+ config.nowPlayingAdminOnly = true
+ })
+
+ it('shows NowPlayingPanel to admin users', () => {
+ mockPermissions = 'admin'
+ render(
+
+
+ ,
+ )
+ expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
+ })
+
+ it('hides NowPlayingPanel from non-admin users', () => {
+ mockPermissions = 'user'
+ render(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ expect(screen.queryByTestId('now-playing-panel')).toBeNull()
+ })
+
+ it('can see NowPlayingPanel when adminOnly is false', () => {
+ config.nowPlayingAdminOnly = false
+ render(
+
+
+ ,
+ )
+ expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
+ })
+
+ it('cannot see NowPlayingPanel when feature is disabled', () => {
+ config.enableNowPlaying = false
+ config.nowPlayingAdminOnly = false
+ render(
+
+
+ ,
+ )
+ expect(screen.queryByTestId('now-playing-panel')).toBeNull()
+ })
+ })
})
diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx
index 44cf9b42c..2648680c7 100644
--- a/ui/src/layout/Layout.jsx
+++ b/ui/src/layout/Layout.jsx
@@ -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]),
diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx
index 509263b42..5c8cafc55 100644
--- a/ui/src/layout/NowPlayingPanel.jsx
+++ b/ui/src/layout/NowPlayingPanel.jsx
@@ -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') {
diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx
index ea4a3568b..a469edb08 100644
--- a/ui/src/layout/NowPlayingPanel.test.jsx
+++ b/ui/src/layout/NowPlayingPanel.test.jsx
@@ -55,7 +55,7 @@ vi.mock('@material-ui/core/styles/useTheme', () => ({
}))
describe('', () => {
- const createMockStore = (overrides = {}) => {
+ const createMockStore = (overrides = {}, libraryOverrides = {}) => {
const defaultState = {
activity: {
nowPlayingCount: 1,
@@ -63,9 +63,17 @@ describe('', () => {
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('', () => {
})
})
+ it('requests all accessible libraries when no explicit selection', async () => {
+ const store = createMockStore(
+ {},
+ { userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [] },
+ )
+ render(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(subsonic.getNowPlaying).toHaveBeenCalledWith([2])
+ })
+
it('displays player name after username', async () => {
const store = createMockStore()
render(
diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js
index 7d93972e0..c623c9de4 100644
--- a/ui/src/subsonic/index.js
+++ b/ui/src/subsonic/index.js
@@ -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(