feat: filter NowPlaying by musicFolderId so the UI hides inaccessible entries

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 <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-06-19 22:36:50 -04:00
parent df74ecb1ec
commit 736e339080
5 changed files with 98 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@ vi.mock('@material-ui/core/styles/useTheme', () => ({
}))
describe('<NowPlayingPanel />', () => {
const createMockStore = (overrides = {}) => {
const createMockStore = (overrides = {}, libraryOverrides = {}) => {
const defaultState = {
activity: {
nowPlayingCount: 1,
@ -63,9 +63,17 @@ describe('<NowPlayingPanel />', () => {
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('<NowPlayingPanel />', () => {
})
})
it('requests all accessible libraries when no explicit selection', async () => {
const store = createMockStore(
{},
{ userLibraries: [{ id: 1 }, { id: 2 }], selectedLibraries: [] },
)
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
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(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
await vi.advanceTimersByTimeAsync(500)
expect(subsonic.getNowPlaying).toHaveBeenCalledWith([2])
})
it('displays player name after username', async () => {
const store = createMockStore()
render(

View File

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