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()
+ })
+ })
})