feat: add event broadcasting for library creation, update, and deletion

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-28 12:27:03 -04:00
commit 202af6eab4
6 changed files with 149 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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