mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
df74ecb1ec
commit
736e339080
@ -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++
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user