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. Backend filtering ensures NowPlaying data respects the AdminOnly setting, returning empty results for non-admin users when enabled.

Frontend changes update the AppBar component to conditionally render NowPlayingPanel based on both the enabled state and admin-only permission check.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-12-15 12:37:59 -05:00
parent ebbe62bbbd
commit 2ff5379b0b
9 changed files with 35 additions and 11 deletions

View File

@@ -81,7 +81,7 @@ type configOptions struct {
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
NowPlaying nowPlayingOptions `json:",omitzero"`
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@@ -207,6 +207,11 @@ type jukeboxOptions struct {
AdminOnly bool
}
type nowPlayingOptions struct {
Enabled bool
AdminOnly bool
}
type backupOptions struct {
Count int
Path string
@@ -258,6 +263,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 {
@@ -374,6 +380,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 {
@@ -574,7 +581,8 @@ func setViperDefaults() {
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
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)

View File

@@ -199,7 +199,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

View File

@@ -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)

View File

@@ -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)

View File

@@ -55,7 +55,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"defaultLanguage": conf.Server.DefaultLanguage,
"defaultUIVolume": conf.Server.DefaultUIVolume,
"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,

View File

@@ -86,7 +86,8 @@ var _ = Describe("serveIndex", func() {
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
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),

View File

@@ -30,6 +30,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
enableNowPlaying: true,
nowPlayingAdminOnly: false,
devShowArtistPage: true,
devUIShowConfig: true,
devNewEventStream: false,

View File

@@ -121,8 +121,10 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
return (
<>
{config.devActivityPanel &&
permissions === 'admin' &&
config.enableNowPlaying && <NowPlayingPanel />}
config.enableNowPlaying &&
(!config.nowPlayingAdminOnly || permissions === 'admin') && (
<NowPlayingPanel />
)}
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
<UserMenu {...rest}>
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />

View File

@@ -39,6 +39,7 @@ describe('<AppBar />', () => {
beforeEach(() => {
config.devActivityPanel = true
config.enableNowPlaying = true
config.nowPlayingAdminOnly = true
store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 0 },
})
@@ -62,4 +63,14 @@ describe('<AppBar />', () => {
)
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
})
it('shows NowPlayingPanel to all users when adminOnly is false', () => {
config.nowPlayingAdminOnly = false
render(
<Provider store={store}>
<AppBar />
</Provider>,
)
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
})
})