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 <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-15 19:57:01 -05:00
parent 6af2b1d777
commit 4298cec37d
3 changed files with 105 additions and 3 deletions

View File

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

View File

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

View File

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