From 4298cec37d73a39dbe8c771ec619cf91e9faa7f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 15 Dec 2025 19:57:01 -0500 Subject: [PATCH] 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 {