From 202af6eab465d9e42ecd1a83c9574cca7c7a4f86 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 28 Jun 2025 12:27:03 -0400 Subject: [PATCH] feat: add event broadcasting for library creation, update, and deletion Signed-off-by: Deluan --- cmd/wire_gen.go | 2 +- core/library.go | 28 +++++++++++- core/library_test.go | 71 ++++++++++++++++++++++++++++- core/mock_library_service.go | 2 +- core/scrobbler/play_tracker_test.go | 6 +++ ui/src/common/LibrarySelector.jsx | 45 +++++++++++++++++- 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d23f932e6..ee5fd025e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -69,7 +69,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { broker := events.GetBroker() scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, scannerScanner) - library := core.NewLibrary(dataStore, scannerScanner, watcher) + library := core.NewLibrary(dataStore, scannerScanner, watcher, broker) router := nativeapi.New(dataStore, share, playlists, insights, library) return router } diff --git a/core/library.go b/core/library.go index 1bb2ea874..406d985a6 100644 --- a/core/library.go +++ b/core/library.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" ) // Scanner interface for triggering scans @@ -43,14 +44,16 @@ type libraryService struct { ds model.DataStore scanner Scanner watcher Watcher + broker events.Broker } // NewLibrary creates a new Library service -func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher) Library { +func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library { return &libraryService{ ds: ds, scanner: scanner, watcher: watcher, + broker: broker, } } @@ -130,6 +133,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository { ds: s.ds, scanner: s.scanner, watcher: s.watcher, + broker: s.broker, } return wrapper } @@ -141,6 +145,7 @@ type libraryRepositoryWrapper struct { ds model.DataStore scanner Scanner watcher Watcher + broker events.Broker } func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { @@ -165,6 +170,13 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { go r.triggerScan(lib, "new") } + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID))) + log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) + } + return strconv.Itoa(lib.ID), nil } @@ -206,6 +218,13 @@ func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols .. } } + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) + } + return nil } @@ -239,6 +258,13 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { go r.triggerScan(lib, "deleted") } + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) + } + return nil } diff --git a/core/library_test.go b/core/library_test.go index 85bdc9234..bfbb4300a 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -3,6 +3,7 @@ package core_test import ( "context" "errors" + "net/http" "os" "path/filepath" "sync" @@ -14,6 +15,7 @@ import ( _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -29,6 +31,7 @@ var _ = Describe("Library Service", func() { var tempDir string var scanner *mockScanner var watcherManager *mockWatcherManager + var broker *mockEventBroker BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -45,7 +48,9 @@ var _ = Describe("Library Service", func() { watcherManager = &mockWatcherManager{ libraryStates: make(map[int]model.Library), } - service = core.NewLibrary(ds, scanner, watcherManager) + // Create a mock event broker + broker = &mockEventBroker{} + service = core.NewLibrary(ds, scanner, watcherManager, broker) ctx = context.Background() // Create a temporary directory for testing valid paths @@ -816,6 +821,51 @@ var _ = Describe("Library Service", func() { }) }) }) + + Describe("Event Broadcasting", func() { + var repo rest.Persistable + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + // Clear any events from broker + broker.Events = []events.Event{} + }) + + It("sends refresh event when creating a library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + + It("sends refresh event when updating a library", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + + It("sends refresh event when deleting a library", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 2, Name: "Library to Delete", Path: tempDir}, + }) + + err := repo.Delete("2") + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + }) }) // mockScanner provides a simple mock implementation of core.Scanner for testing @@ -909,3 +959,22 @@ func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) { defer m.mu.Unlock() m.libraryStates[lib.ID] = lib } + +// mockEventBroker provides a mock implementation of events.Broker for testing +type mockEventBroker struct { + http.Handler + Events []events.Event + mu sync.RWMutex +} + +func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) { + m.mu.Lock() + defer m.mu.Unlock() + m.Events = append(m.Events, event) +} + +func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) { + m.mu.Lock() + defer m.mu.Unlock() + m.Events = append(m.Events, event) +} diff --git a/core/mock_library_service.go b/core/mock_library_service.go index b925f8e4c..46134fa28 100644 --- a/core/mock_library_service.go +++ b/core/mock_library_service.go @@ -18,7 +18,7 @@ type MockLibraryService struct { Libraries model.Libraries } -// NewMockLibraryService creates a new mock library service with some default test data +// NewMockLibraryService creates a new mock library service for testing func NewMockLibraryService() *MockLibraryService { return &MockLibraryService{ Libraries: model.Libraries{ diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0447aa142..7b4785bb5 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -429,6 +429,12 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.events = append(f.events, event) } +func (f *fakeEventBroker) SendBroadcastMessage(_ 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() diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx index cb0ad0ca0..671bc8a84 100644 --- a/ui/src/common/LibrarySelector.jsx +++ b/ui/src/common/LibrarySelector.jsx @@ -74,12 +74,18 @@ const LibrarySelector = () => { const refresh = useRefresh() const [anchorEl, setAnchorEl] = useState(null) const [open, setOpen] = useState(false) + const [lastRefreshTime, setLastRefreshTime] = useState(Date.now()) const { userLibraries, selectedLibraries } = useSelector( (state) => state.library, ) - // Load user's libraries when component mounts + // Watch for refresh events to reload libraries + const refreshData = useSelector( + (state) => state.activity?.refresh || { lastReceived: lastRefreshTime }, + ) + + // Load user's libraries when component mounts or when refresh events occur useEffect(() => { const loadUserLibraries = async () => { // Get userId from localStorage (not from identity which returns username) @@ -102,6 +108,43 @@ const LibrarySelector = () => { loadUserLibraries() }, [dataProvider, dispatch]) + // Watch for refresh events and reload libraries when they occur + useEffect(() => { + const { resources, lastReceived } = refreshData + + if (lastReceived <= lastRefreshTime) { + return + } + + const shouldReload = resources && + (resources['*'] === '*' || + resources.library || + resources.user) + + if (shouldReload) { + setLastRefreshTime(lastReceived) + + const loadUserLibraries = async () => { + const userId = localStorage.getItem('userId') + if (userId) { + try { + const { data } = await dataProvider.getOne('user', { id: userId }) + const libraries = data.libraries || [] + dispatch(setUserLibraries(libraries)) + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + 'Could not reload user libraries after refresh event:', + error, + ) + } + } + } + + loadUserLibraries() + } + }, [dataProvider, dispatch, refreshData, lastRefreshTime]) + // Don't render if user has no libraries or only has one library if (!userLibraries.length || userLibraries.length === 1) { return null