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(