feat(ui): add Now Playing panel for admins (#4209)

* feat(ui): add Now Playing panel and integrate now playing count updates

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: check return value in test to satisfy linter

* fix: format React code with prettier

* fix: resolve race condition in play tracker test

* fix: log error when fetching now playing data fails

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): refactor Now Playing panel with new components and error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): adjust padding and height in Now Playing panel for improved layout

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(cache): add automatic cleanup to prevent goroutine leak on cache garbage collection

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-10 17:22:13 -04:00
committed by GitHub
parent a65140b965
commit 76042ba173
16 changed files with 744 additions and 3 deletions
+6
View File
@@ -51,6 +51,10 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{ds: ds, playMap: m, broker: broker}
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
ctx := events.BroadcastToAll(context.Background())
broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
})
p.scrobblers = make(map[string]Scrobbler)
var enabled []string
for name, constructor := range constructors {
@@ -85,6 +89,8 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
ttl := time.Duration(int(mf.Duration)+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
ctx = events.BroadcastToAll(ctx)
p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf)
+47 -1
View File
@@ -3,6 +3,8 @@ package scrobbler
import (
"context"
"errors"
"net/http"
"sync"
"time"
"github.com/navidrome/navidrome/consts"
@@ -19,6 +21,7 @@ var _ = Describe("PlayTracker", func() {
var ctx context.Context
var ds model.DataStore
var tracker PlayTracker
var eventBroker *fakeEventBroker
var track model.MediaFile
var album model.Album
var artist1 model.Artist
@@ -37,7 +40,8 @@ var _ = Describe("PlayTracker", func() {
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
tracker = newPlayTracker(ds, events.GetBroker())
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker)
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
track = model.MediaFile{
@@ -99,6 +103,16 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
})
It("sends event with count", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
eventList := eventBroker.getEvents()
Expect(eventList).ToNot(BeEmpty())
evt, ok := eventList[0].(*events.NowPlayingCount)
Expect(ok).To(BeTrue())
Expect(evt.Count).To(Equal(1))
})
})
Describe("GetNowPlaying", func() {
@@ -127,6 +141,18 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("Expiration events", func() {
It("sends event when entry expires", func() {
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
eventList := eventBroker.getEvents()
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
Expect(ok).To(BeTrue())
Expect(evt.Count).To(Equal(0))
})
})
Describe("Submit", func() {
It("sends track to agent", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
@@ -243,3 +269,23 @@ func _p(id, name string, sortName ...string) model.Participant {
}
return p
}
type fakeEventBroker struct {
http.Handler
events []events.Event
mu sync.Mutex
}
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, event)
}
func (f *fakeEventBroker) getEvents() []events.Event {
f.mu.Lock()
defer f.mu.Unlock()
return f.events
}
var _ events.Broker = (*fakeEventBroker)(nil)