diff --git a/conf/configuration.go b/conf/configuration.go index 61448d315..9d39f83ad 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -85,7 +85,7 @@ type configOptions struct { UISearchDebounceMs int EnableReplayGain bool EnableCoverAnimation bool - EnableNowPlaying bool + NowPlaying nowPlayingOptions `json:",omitzero"` GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -222,6 +222,11 @@ type jukeboxOptions struct { AdminOnly bool } +type nowPlayingOptions struct { + Enabled bool + AdminOnly bool +} + type backupOptions struct { Count int Path string @@ -281,6 +286,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + mapDeprecatedOption("EnableNowPlaying", "NowPlaying.Enabled") err := viper.Unmarshal(&Server) if err != nil { @@ -407,6 +413,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + logDeprecatedOptions("EnableNowPlaying", "NowPlaying.Enabled") // Call init hooks for _, hook := range hooks { @@ -658,7 +665,8 @@ func setViperDefaults() { viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) - viper.SetDefault("enablenowplaying", true) + viper.SetDefault("nowplaying.enabled", true) + viper.SetDefault("nowplaying.adminonly", false) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f059d739a..b07ff71fe 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -194,7 +194,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache 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 d1338ca39..f7c7fde54 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -88,7 +88,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug shutdown: make(chan struct{}), workerDone: make(chan struct{}), } - if conf.Server.EnableNowPlaying { + if conf.Server.NowPlaying.Enabled { m.OnExpiration(func(_ string, _ NowPlayingInfo) { broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) }) @@ -216,7 +216,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. ttl := time.Duration(remaining+5) * time.Second _ = p.playMap.AddWithTTL(playerId, info, ttl) - if conf.Server.EnableNowPlaying { + if conf.Server.NowPlaying.Enabled { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } player, _ := request.PlayerFrom(ctx) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..684733b55 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -165,7 +165,7 @@ var _ = Describe("PlayTracker", func() { }) It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false + conf.Server.NowPlaying.Enabled = false err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) Expect(err).ToNot(HaveOccurred()) Expect(eventBroker.getEvents()).To(BeEmpty()) @@ -221,7 +221,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 := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) diff --git a/server/serve_index.go b/server/serve_index.go index 92ef47e23..8fc7ff8f9 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -56,7 +56,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultUIVolume": conf.Server.DefaultUIVolume, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "enableCoverAnimation": conf.Server.EnableCoverAnimation, - "enableNowPlaying": conf.Server.EnableNowPlaying, + "enableNowPlaying": conf.Server.NowPlaying.Enabled, + "nowPlayingAdminOnly": conf.Server.NowPlaying.AdminOnly, "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e08a42643..92e3011fc 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -87,7 +87,8 @@ var _ = Describe("serveIndex", func() { Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), 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 56cf469c5..6bc2111ae 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -3,10 +3,10 @@ package subsonic import ( "context" "net/http" + "slices" "strconv" "time" - "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/filter" @@ -209,18 +209,24 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { return nil, err } + // Get user's accessible library IDs for filtering + accessibleLibraryIds, _ := selectedMusicFolderIds(r, false) + response := newResponse() response.NowPlaying = &responses.NowPlaying{} - var i int32 - response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { - return responses.NowPlayingEntry{ + // Filter entries to only include tracks from libraries the user has access to + for i, np := range npInfo { + if !slices.Contains(accessibleLibraryIds, np.MediaFile.LibraryID) { + continue + } + response.NowPlaying.Entry = append(response.NowPlaying.Entry, responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: int32(i), // Fake numeric playerId, it does not seem to be used for anything PlayerName: np.PlayerName, - } - }) + }) + } return response, nil } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 63c2614cd..326cd0d32 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "net/http/httptest" + "time" "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 +541,131 @@ var _ = Describe("Album Lists", func() { }) }) }) + + Describe("GetNowPlaying", func() { + var mockPlayTracker *mockPlayTrackerForAlbumLists + var user model.User + + BeforeEach(func() { + mockPlayTracker = &mockPlayTrackerForAlbumLists{} + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, + } + }) + + It("should filter entries by user's accessible libraries", func() { + mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{ + { + 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 3 not accessible to user + Start: time.Now(), + Username: "user2", + PlayerId: "player2", + PlayerName: "Player 2", + }, + { + MediaFile: model.MediaFile{ID: "3", Title: "Track 3", LibraryID: 2}, + Start: time.Now(), + Username: "user3", + PlayerId: "player3", + PlayerName: "Player 3", + }, + } + router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, 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)) + // Should only include tracks from libraries 1 and 2, not library 3 + Expect(resp.NowPlaying.Entry[0].Title).To(Equal("Track 1")) + Expect(resp.NowPlaying.Entry[1].Title).To(Equal("Track 3")) + }) + + It("should return empty list when user has no accessible libraries", func() { + mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{ + { + MediaFile: model.MediaFile{ID: "1", Title: "Track 1", LibraryID: 5}, // Library not accessible + Start: time.Now(), + Username: "user1", + PlayerId: "player1", + PlayerName: "Player 1", + }, + } + router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil) + userWithNoLibraries := model.User{ID: "no-lib-user", Libraries: []model.Library{}} + ctx := request.WithUser(context.Background(), userWithNoLibraries) + r := newGetRequest() + r = r.WithContext(ctx) + + resp, err := router.GetNowPlaying(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.NowPlaying.Entry).To(HaveLen(0)) + }) + + It("should return all entries when user has access to all libraries", func() { + mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{ + { + 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) + 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)) + }) + }) }) + +// mockPlayTrackerForAlbumLists is a minimal mock implementing scrobbler.PlayTracker for GetNowPlaying tests +type mockPlayTrackerForAlbumLists struct { + NowPlayingData []scrobbler.NowPlayingInfo + Error error +} + +func (m *mockPlayTrackerForAlbumLists) NowPlaying(_ context.Context, _ string, _ string, _ string, _ int) error { + return m.Error +} + +func (m *mockPlayTrackerForAlbumLists) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { + if m.Error != nil { + return nil, m.Error + } + return m.NowPlayingData, nil +} + +func (m *mockPlayTrackerForAlbumLists) Submit(_ context.Context, _ []scrobbler.Submission) error { + return m.Error +} + +var _ scrobbler.PlayTracker = (*mockPlayTrackerForAlbumLists)(nil) diff --git a/ui/src/config.js b/ui/src/config.js index 5acf10b69..2ecd4060a 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -31,6 +31,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + 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..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() + }) + }) })