From 08e632d9186aab3e49e001c02dd237f96da2b2b9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 15 Dec 2025 12:37:59 -0500 Subject: [PATCH 1/6] 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 --- conf/configuration.go | 12 ++++++++++-- core/metrics/insights.go | 2 +- core/scrobbler/play_tracker.go | 4 ++-- core/scrobbler/play_tracker_test.go | 6 +++--- server/serve_index.go | 3 ++- server/serve_index_test.go | 3 ++- ui/src/config.js | 1 + ui/src/layout/AppBar.jsx | 6 ++++-- ui/src/layout/AppBar.test.jsx | 11 +++++++++++ 9 files changed, 36 insertions(+), 12 deletions(-) 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/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..510af7eaa 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -121,8 +121,10 @@ const CustomUserMenu = ({ onClick, ...rest }) => { return ( <> {config.devActivityPanel && - permissions === 'admin' && - config.enableNowPlaying && } + config.enableNowPlaying && + (!config.nowPlayingAdminOnly || permissions === 'admin') && ( + + )} {config.devActivityPanel && permissions === 'admin' && } diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index f39dd75cb..3b3015f2f 100644 --- a/ui/src/layout/AppBar.test.jsx +++ b/ui/src/layout/AppBar.test.jsx @@ -39,6 +39,7 @@ describe('', () => { beforeEach(() => { config.devActivityPanel = true config.enableNowPlaying = true + config.nowPlayingAdminOnly = true store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -62,4 +63,14 @@ 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() + }) }) From 6af2b1d777c302586f11d078403c5cbbcf8d4a3a Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 15 Dec 2025 13:04:49 -0500 Subject: [PATCH 2/6] 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. --- ui/src/layout/AppBar.test.jsx | 67 ++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index 3b3015f2f..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: () => [], })) @@ -40,6 +41,7 @@ describe('', () => { config.devActivityPanel = true config.enableNowPlaying = true config.nowPlayingAdminOnly = true + mockPermissions = 'admin' store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -73,4 +75,67 @@ describe('', () => { ) 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() + }) + }) }) From 4298cec37d73a39dbe8c771ec619cf91e9faa7f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 15 Dec 2025 19:57:01 -0500 Subject: [PATCH 3/6] 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 --- server/subsonic/album_lists.go | 11 ++- server/subsonic/album_lists_test.go | 94 ++++++++++++++++++++++++ server/subsonic/media_annotation_test.go | 3 +- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..b3902b65c 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -6,6 +6,7 @@ import ( "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 +204,20 @@ 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{} 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..1c8863dd5 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,94 @@ 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")) + }) + + 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 { From df74ecb1ec1a2306c444ecad189f92da180bb5df Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 19 Jun 2026 22:05:12 -0400 Subject: [PATCH 4/6] 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 --- ui/src/layout/AppBar.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 510af7eaa..eaebad94e 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -118,13 +118,13 @@ const CustomUserMenu = ({ onClick, ...rest }) => { ) } + const canViewNowPlaying = + config.enableNowPlaying && + (!config.nowPlayingAdminOnly || permissions === 'admin') + return ( <> - {config.devActivityPanel && - config.enableNowPlaying && - (!config.nowPlayingAdminOnly || permissions === 'admin') && ( - - )} + {config.devActivityPanel && canViewNowPlaying && } {config.devActivityPanel && permissions === 'admin' && } From 736e33908058d460609580edb97824988aea5651 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 19 Jun 2026 22:36:50 -0400 Subject: [PATCH 5/6] feat: filter NowPlaying by musicFolderId so the UI hides inaccessible entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/subsonic/album_lists.go | 14 ++++++++ server/subsonic/album_lists_test.go | 29 +++++++++++++++++ ui/src/layout/NowPlayingPanel.jsx | 6 +++- ui/src/layout/NowPlayingPanel.test.jsx | 44 ++++++++++++++++++++++++-- ui/src/subsonic/index.js | 9 +++++- 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index b3902b65c..5cc319cef 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "net/http" + "slices" "strconv" "time" @@ -218,6 +219,19 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { return nil, err } + // 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 1c8863dd5..efd1362da 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -591,6 +591,35 @@ var _ = Describe("Album Lists", func() { 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()) 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( From dab37f6a898fad83bda9111da9288d3674fbe959 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 20 Jun 2026 00:03:24 -0400 Subject: [PATCH 6/6] 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 --- ui/src/common/LibrarySelector.jsx | 40 +++------------- ui/src/common/index.js | 1 + ui/src/common/useUserLibraries.js | 40 ++++++++++++++++ ui/src/common/useUserLibraries.test.js | 65 ++++++++++++++++++++++++++ ui/src/layout/Layout.jsx | 3 +- 5 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 ui/src/common/useUserLibraries.js create mode 100644 ui/src/common/useUserLibraries.test.js 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/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]),