mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
4 Commits
new-plugin
...
feat/now-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8dacb709 | ||
|
|
7c13c8182a | ||
|
|
27d81ffd96 | ||
|
|
2ff5379b0b |
@@ -81,7 +81,7 @@ type configOptions struct {
|
||||
DefaultUIVolume int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
NowPlaying nowPlayingOptions `json:",omitzero"`
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
@@ -207,6 +207,11 @@ type jukeboxOptions struct {
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
type nowPlayingOptions struct {
|
||||
Enabled bool
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path string
|
||||
@@ -258,6 +263,7 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("EnableNowPlaying", "NowPlaying.Enabled")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
@@ -374,6 +380,7 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("EnableNowPlaying", "NowPlaying.Enabled")
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -574,7 +581,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("nowplaying.enabled", true)
|
||||
viper.SetDefault("nowplaying.adminonly", false)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
|
||||
@@ -199,7 +199,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableNowPlaying = conf.Server.NowPlaying.Enabled
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
|
||||
@@ -88,7 +88,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
shutdown: make(chan struct{}),
|
||||
workerDone: make(chan struct{}),
|
||||
}
|
||||
if conf.Server.EnableNowPlaying {
|
||||
if conf.Server.NowPlaying.Enabled {
|
||||
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
||||
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
|
||||
})
|
||||
@@ -216,7 +216,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
|
||||
ttl := time.Duration(remaining+5) * time.Second
|
||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||
if conf.Server.EnableNowPlaying {
|
||||
if conf.Server.NowPlaying.Enabled {
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
|
||||
@@ -165,7 +165,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does not send event when disabled", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
conf.Server.NowPlaying.Enabled = false
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventBroker.getEvents()).To(BeEmpty())
|
||||
@@ -221,7 +221,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does not send event when disabled", func() {
|
||||
conf.Server.EnableNowPlaying = false
|
||||
conf.Server.NowPlaying.Enabled = false
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
|
||||
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
|
||||
|
||||
@@ -55,7 +55,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||
"enableNowPlaying": conf.Server.NowPlaying.Enabled,
|
||||
"nowPlayingAdminOnly": conf.Server.NowPlaying.AdminOnly,
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
||||
"devActivityPanel": conf.Server.DevActivityPanel,
|
||||
|
||||
@@ -86,7 +86,8 @@ var _ = Describe("serveIndex", func() {
|
||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.NowPlaying.Enabled = true }, "enableNowPlaying", true),
|
||||
Entry("nowPlayingAdminOnly", func() { conf.Server.NowPlaying.AdminOnly = true }, "nowPlayingAdminOnly", true),
|
||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
||||
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
|
||||
|
||||
@@ -3,10 +3,10 @@ package subsonic
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
@@ -209,18 +209,24 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get user's accessible library IDs for filtering
|
||||
accessibleLibraryIds, _ := selectedMusicFolderIds(r, false)
|
||||
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
var i int32
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
|
||||
return responses.NowPlayingEntry{
|
||||
// Filter entries to only include tracks from libraries the user has access to
|
||||
for i, np := range npInfo {
|
||||
if !slices.Contains(accessibleLibraryIds, np.MediaFile.LibraryID) {
|
||||
continue
|
||||
}
|
||||
response.NowPlaying.Entry = append(response.NowPlaying.Entry, responses.NowPlayingEntry{
|
||||
Child: childFromMediaFile(ctx, np.MediaFile),
|
||||
UserName: np.Username,
|
||||
MinutesAgo: int32(time.Since(np.Start).Minutes()),
|
||||
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
|
||||
PlayerId: int32(i), // Fake numeric playerId, it does not seem to be used for anything
|
||||
PlayerName: np.PlayerName,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"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 +541,131 @@ var _ = Describe("Album Lists", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
var mockPlayTracker *mockPlayTrackerForAlbumLists
|
||||
var user model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlayTracker = &mockPlayTrackerForAlbumLists{}
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should filter entries by user's accessible libraries", func() {
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{
|
||||
{
|
||||
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 3 not accessible to user
|
||||
Start: time.Now(),
|
||||
Username: "user2",
|
||||
PlayerId: "player2",
|
||||
PlayerName: "Player 2",
|
||||
},
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "3", Title: "Track 3", LibraryID: 2},
|
||||
Start: time.Now(),
|
||||
Username: "user3",
|
||||
PlayerId: "player3",
|
||||
PlayerName: "Player 3",
|
||||
},
|
||||
}
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, 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))
|
||||
// Should only include tracks from libraries 1 and 2, not library 3
|
||||
Expect(resp.NowPlaying.Entry[0].Title).To(Equal("Track 1"))
|
||||
Expect(resp.NowPlaying.Entry[1].Title).To(Equal("Track 3"))
|
||||
})
|
||||
|
||||
It("should return empty list when user has no accessible libraries", func() {
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{
|
||||
{
|
||||
MediaFile: model.MediaFile{ID: "1", Title: "Track 1", LibraryID: 5}, // Library not accessible
|
||||
Start: time.Now(),
|
||||
Username: "user1",
|
||||
PlayerId: "player1",
|
||||
PlayerName: "Player 1",
|
||||
},
|
||||
}
|
||||
router := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, mockPlayTracker, nil, nil, nil)
|
||||
userWithNoLibraries := model.User{ID: "no-lib-user", Libraries: []model.Library{}}
|
||||
ctx := request.WithUser(context.Background(), userWithNoLibraries)
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetNowPlaying(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NowPlaying.Entry).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return all entries when user has access to all libraries", func() {
|
||||
mockPlayTracker.NowPlayingData = []scrobbler.NowPlayingInfo{
|
||||
{
|
||||
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)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockPlayTrackerForAlbumLists is a minimal mock implementing scrobbler.PlayTracker for GetNowPlaying tests
|
||||
type mockPlayTrackerForAlbumLists struct {
|
||||
NowPlayingData []scrobbler.NowPlayingInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
func (m *mockPlayTrackerForAlbumLists) NowPlaying(_ context.Context, _ string, _ string, _ string, _ int) error {
|
||||
return m.Error
|
||||
}
|
||||
|
||||
func (m *mockPlayTrackerForAlbumLists) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
return m.NowPlayingData, nil
|
||||
}
|
||||
|
||||
func (m *mockPlayTrackerForAlbumLists) Submit(_ context.Context, _ []scrobbler.Submission) error {
|
||||
return m.Error
|
||||
}
|
||||
|
||||
var _ scrobbler.PlayTracker = (*mockPlayTrackerForAlbumLists)(nil)
|
||||
|
||||
@@ -30,6 +30,7 @@ const defaultConfig = {
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
enableNowPlaying: true,
|
||||
nowPlayingAdminOnly: false,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
devNewEventStream: false,
|
||||
|
||||
@@ -121,8 +121,10 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
||||
return (
|
||||
<>
|
||||
{config.devActivityPanel &&
|
||||
permissions === 'admin' &&
|
||||
config.enableNowPlaying && <NowPlayingPanel />}
|
||||
config.enableNowPlaying &&
|
||||
(!config.nowPlayingAdminOnly || permissions === 'admin') && (
|
||||
<NowPlayingPanel />
|
||||
)}
|
||||
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
|
||||
<UserMenu {...rest}>
|
||||
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
||||
|
||||
@@ -8,11 +8,12 @@ import AppBar from './AppBar'
|
||||
import config from '../config'
|
||||
|
||||
let store
|
||||
let mockPermissions = 'admin'
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>,
|
||||
useTranslate: () => (x) => x,
|
||||
usePermissions: () => ({ permissions: 'admin' }),
|
||||
usePermissions: () => ({ permissions: mockPermissions }),
|
||||
getResources: () => [],
|
||||
}))
|
||||
|
||||
@@ -39,6 +40,8 @@ describe('<AppBar />', () => {
|
||||
beforeEach(() => {
|
||||
config.devActivityPanel = true
|
||||
config.enableNowPlaying = true
|
||||
config.nowPlayingAdminOnly = true
|
||||
mockPermissions = 'admin'
|
||||
store = createStore(combineReducers({ activity: activityReducer }), {
|
||||
activity: { nowPlayingCount: 0 },
|
||||
})
|
||||
@@ -62,4 +65,77 @@ describe('<AppBar />', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows NowPlayingPanel to all users when adminOnly is false', () => {
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('admin-only mode', () => {
|
||||
beforeEach(() => {
|
||||
config.nowPlayingAdminOnly = true
|
||||
})
|
||||
|
||||
it('shows NowPlayingPanel to admin users', () => {
|
||||
mockPermissions = 'admin'
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides NowPlayingPanel from non-admin users', () => {
|
||||
mockPermissions = 'user'
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-admin users', () => {
|
||||
beforeEach(() => {
|
||||
mockPermissions = 'user'
|
||||
})
|
||||
|
||||
it('cannot see NowPlayingPanel when adminOnly is true', () => {
|
||||
config.nowPlayingAdminOnly = true
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('can see NowPlayingPanel when adminOnly is false', () => {
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cannot see NowPlayingPanel when feature is disabled', () => {
|
||||
config.enableNowPlaying = false
|
||||
config.nowPlayingAdminOnly = false
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user