diff --git a/conf/configuration.go b/conf/configuration.go index 83793bd43..9443fe9e3 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -96,7 +96,7 @@ type configOptions struct { UICoverArtSize int EnableReplayGain bool EnableCoverAnimation bool - EnableNowPlaying bool + NowPlaying nowPlayingOptions `json:",omitzero"` UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool @@ -250,6 +250,11 @@ type jukeboxOptions struct { AdminOnly bool } +type nowPlayingOptions struct { + Enabled bool + AdminOnly bool +} + type backupOptions struct { Count int Path Dir @@ -346,6 +351,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( @@ -472,6 +478,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") @@ -803,7 +810,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 78391779a..e98a16aee 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 e21db42d2..ce60a5431 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -133,7 +133,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) @@ -400,7 +400,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.enqueuePlaybackReport(ctx, stoppedInfo) } - 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 f49d9a0bf..dcb7fead0 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -167,7 +167,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) @@ -479,8 +479,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 a538daf1a..b7de74eff 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 041a3b8f2..dc9c7ab79 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 1b16dfc68..d72ca30ba 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -248,11 +248,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 ac8d7f62c..8c5668b95 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -29,6 +29,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(