From 736e33908058d460609580edb97824988aea5651 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 19 Jun 2026 22:36:50 -0400 Subject: [PATCH] feat: filter NowPlaying by musicFolderId so the UI hides inaccessible entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for the standard Subsonic musicFolderId parameter on the getNowPlaying endpoint. When provided, results are restricted to those libraries (validated against the user's access); when absent, all entries are returned, preserving the spec behavior for third-party clients. The web UI now passes the user's accessible libraries (the active picker selection when set, otherwise all of the user's libraries), so the NowPlaying panel no longer shows entries the user cannot open — which previously rendered with broken cover art and dead album/artist links. Signed-off-by: Deluan --- server/subsonic/album_lists.go | 14 ++++++++ server/subsonic/album_lists_test.go | 29 +++++++++++++++++ ui/src/layout/NowPlayingPanel.jsx | 6 +++- ui/src/layout/NowPlayingPanel.test.jsx | 44 ++++++++++++++++++++++++-- ui/src/subsonic/index.js | 9 +++++- 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index b3902b65c..5cc319cef 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "net/http" + "slices" "strconv" "time" @@ -218,6 +219,19 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { return nil, err } + // Optionally restrict to specific libraries via the standard Subsonic musicFolderId param. + // When absent, all entries are returned, per the getNowPlaying spec. + requestedFolderIds, _ := req.Params(r).Ints("musicFolderId") + if len(requestedFolderIds) > 0 { + folderIds, ferr := selectedMusicFolderIds(r, false) + if ferr != nil { + return nil, ferr + } + npInfo = slice.Filter(npInfo, func(np scrobbler.PlaybackSession) bool { + return slices.Contains(folderIds, np.MediaFile.LibraryID) + }) + } + 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 1c8863dd5..efd1362da 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -591,6 +591,35 @@ var _ = Describe("Album Lists", func() { Expect(resp.NowPlaying.Entry[1].Title).To(Equal("Track 2")) }) + It("should filter entries by the musicFolderId parameter when provided", func() { + 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: 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, nil, nil, nil) + ctx := request.WithUser(context.Background(), user) + r := newGetRequest("musicFolderId=1") + r = r.WithContext(ctx) + + resp, err := router.GetNowPlaying(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.NowPlaying.Entry).To(HaveLen(1)) + Expect(resp.NowPlaying.Entry[0].Title).To(Equal("Track 1")) + }) + Context("when NowPlaying.AdminOnly is enabled", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 509263b42..5c8cafc55 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -21,6 +21,7 @@ import { import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' +import { useSelectedLibraries } from '../common/useLibrarySelection' import { nowPlayingCountSync } from '../actions' import { formatDuration } from '../utils' import config from '../config' @@ -370,6 +371,9 @@ const NowPlayingPanel = () => { const serverUp = useSelector( (state) => !!state.activity.serverStart.startTime, ) + // Limit results to libraries the user can access (explicit picker selection, + // or all accessible libraries when nothing is narrowed). + const libraryIds = useSelectedLibraries() const translate = useTranslate() const notify = useNotify() const theme = useTheme() @@ -406,7 +410,7 @@ const NowPlayingPanel = () => { const doFetchRef = useRef() doFetchRef.current = () => subsonic - .getNowPlaying() + .getNowPlaying(libraryIds) .then((resp) => resp.json['subsonic-response']) .then((data) => { if (data.status === 'ok') { diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index ea4a3568b..a469edb08 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -55,7 +55,7 @@ vi.mock('@material-ui/core/styles/useTheme', () => ({ })) describe('', () => { - const createMockStore = (overrides = {}) => { + const createMockStore = (overrides = {}, libraryOverrides = {}) => { const defaultState = { activity: { nowPlayingCount: 1, @@ -63,9 +63,17 @@ describe('', () => { streamReconnected: 0, ...overrides, }, + library: { + userLibraries: [], + selectedLibraries: [], + ...libraryOverrides, + }, } return createStore( - combineReducers({ activity: activityReducer }), + combineReducers({ + activity: activityReducer, + library: (state = defaultState.library) => state, + }), defaultState, ) } @@ -123,6 +131,38 @@ describe('', () => { }) }) + it('requests all accessible libraries when no explicit selection', async () => { + const store = createMockStore( + {}, + { userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [] }, + ) + render( + + + , + ) + + await vi.advanceTimersByTimeAsync(500) + + expect(subsonic.getNowPlaying).toHaveBeenCalledWith([1, 2]) + }) + + it('requests only the selected libraries when narrowed', async () => { + const store = createMockStore( + {}, + { userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [2] }, + ) + render( + + + , + ) + + await vi.advanceTimersByTimeAsync(500) + + expect(subsonic.getNowPlaying).toHaveBeenCalledWith([2]) + }) + it('displays player name after username', async () => { const store = createMockStore() render( diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..c623c9de4 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -70,7 +70,14 @@ const startScan = (options) => httpClient(url('startScan', null, options)) const getScanStatus = () => httpClient(url('getScanStatus')) -const getNowPlaying = () => httpClient(url('getNowPlaying')) +const getNowPlaying = (musicFolderId) => + httpClient( + url( + 'getNowPlaying', + null, + musicFolderId?.length ? { musicFolderId } : undefined, + ), + ) const getAvatarUrl = (username, size) => baseUrl(