feat(library): add/remove watchers on demand, not only when server starts

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-25 12:13:09 -04:00
commit ee18a04da9
6 changed files with 361 additions and 57 deletions

View file

@ -19,11 +19,17 @@ import (
"github.com/navidrome/navidrome/model/request"
)
// Scanner defines the interface for triggering library scans
// Scanner interface for triggering scans
type Scanner interface {
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
}
// Watcher interface for managing file system watchers
type Watcher interface {
Watch(ctx context.Context, lib *model.Library) error
StopWatching(ctx context.Context, libraryID int) error
}
// Library provides business logic for library management and user-library associations
type Library interface {
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
@ -36,11 +42,16 @@ type Library interface {
type libraryService struct {
ds model.DataStore
scanner Scanner
watcher Watcher
}
// NewLibrary creates a new Library service
func NewLibrary(ds model.DataStore, scanner Scanner) Library {
return &libraryService{ds: ds, scanner: scanner}
func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher) Library {
return &libraryService{
ds: ds,
scanner: scanner,
watcher: watcher,
}
}
// User-library association operations
@ -118,6 +129,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
Repository: repo.(rest.Repository),
ds: s.ds,
scanner: s.scanner,
watcher: s.watcher,
}
return wrapper
}
@ -128,6 +140,7 @@ type libraryRepositoryWrapper struct {
ctx context.Context
ds model.DataStore
scanner Scanner
watcher Watcher
}
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
@ -141,7 +154,13 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return "", r.mapError(err)
}
// Trigger scan after successful library creation
// Start watcher and trigger scan after successful library creation
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "new")
}
@ -174,9 +193,17 @@ func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ..
return r.mapError(err)
}
// Trigger scan if path was updated
if pathChanged && r.scanner != nil {
go r.triggerScan(lib, "updated")
// Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "updated")
}
}
return nil
@ -201,7 +228,13 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
return r.mapError(err)
}
// Trigger scan after successful library deletion to clean up orphaned data
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
if r.watcher != nil {
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "deleted")
}

View file

@ -28,6 +28,7 @@ var _ = Describe("Library Service", func() {
var ctx context.Context
var tempDir string
var scanner *mockScanner
var watcherManager *mockWatcherManager
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -40,7 +41,11 @@ var _ = Describe("Library Service", func() {
// Create a mock scanner that tracks calls
scanner = &mockScanner{}
service = core.NewLibrary(ds, scanner)
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
}
service = core.NewLibrary(ds, scanner, watcherManager)
ctx = context.Background()
// Create a temporary directory for testing valid paths
@ -714,6 +719,102 @@ var _ = Describe("Library Service", func() {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
Context("Watcher Integration", func() {
It("starts watcher when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was started
Eventually(func() int {
return watcherManager.lenStarted()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1))
Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library"))
Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir))
})
It("restarts watcher when library path is updated", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Simulate that this library already has a watcher
watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir})
// Create a new temp directory for the update
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
// Update library with new path
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was restarted
Eventually(func() int {
return watcherManager.lenRestarted()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1))
Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir))
})
It("does not restart watcher when only library name is updated", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Update library with same path but different name
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was NOT restarted (since path didn't change)
Consistently(func() int {
return watcherManager.lenRestarted()
}, "100ms", "10ms").Should(Equal(0))
})
It("stops watcher when library is deleted", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
err := repo.Delete("1")
Expect(err).NotTo(HaveOccurred())
// Verify watcher was stopped
Eventually(func() int {
return watcherManager.lenStopped()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.StoppedWatchers[0]).To(Equal(1))
})
It("does not stop watcher when library deletion fails", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
// Mock deletion to fail by trying to delete non-existent library
err := repo.Delete("999")
Expect(err).To(HaveOccurred())
// Verify watcher was NOT stopped since deletion failed
Consistently(func() int {
return watcherManager.lenStopped()
}, "100ms", "10ms").Should(Equal(0))
})
})
})
})
@ -741,3 +842,70 @@ func (m *mockScanner) len() int {
defer m.mu.RUnlock()
return len(m.ScanCalls)
}
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
type mockWatcherManager struct {
StartedWatchers []model.Library
StoppedWatchers []int
RestartedWatchers []model.Library
libraryStates map[int]model.Library // Track which libraries we know about
mu sync.RWMutex
}
func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error {
m.mu.Lock()
defer m.mu.Unlock()
// Check if we already know about this library ID
if _, exists := m.libraryStates[lib.ID]; exists {
// This is a restart - the library already existed
// Update our tracking and record the restart
for i, startedLib := range m.StartedWatchers {
if startedLib.ID == lib.ID {
m.StartedWatchers[i] = *lib
break
}
}
m.RestartedWatchers = append(m.RestartedWatchers, *lib)
m.libraryStates[lib.ID] = *lib
return nil
}
// This is a new library - first time we're seeing it
m.StartedWatchers = append(m.StartedWatchers, *lib)
m.libraryStates[lib.ID] = *lib
return nil
}
func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error {
m.mu.Lock()
defer m.mu.Unlock()
m.StoppedWatchers = append(m.StoppedWatchers, libraryID)
return nil
}
func (m *mockWatcherManager) lenStarted() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.StartedWatchers)
}
func (m *mockWatcherManager) lenStopped() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.StoppedWatchers)
}
func (m *mockWatcherManager) lenRestarted() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.RestartedWatchers)
}
// simulateExistingLibrary simulates the scenario where a library already exists
// and has a watcher running (used by tests to set up the initial state)
func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) {
m.mu.Lock()
defer m.mu.Unlock()
m.libraryStates[lib.ID] = lib
}

View file

@ -28,14 +28,14 @@ func NewMockLibraryService() *MockLibraryService {
}
}
func (m *MockLibraryService) GetAll(ctx context.Context) (model.Libraries, error) {
func (m *MockLibraryService) getAll(ctx context.Context) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
return m.Libraries, nil
}
func (m *MockLibraryService) Get(ctx context.Context, id int) (*model.Library, error) {
func (m *MockLibraryService) get(ctx context.Context, id int) (*model.Library, error) {
if m.Err != nil {
return nil, m.Err
}
@ -47,7 +47,7 @@ func (m *MockLibraryService) Get(ctx context.Context, id int) (*model.Library, e
return nil, model.ErrNotFound
}
func (m *MockLibraryService) Create(ctx context.Context, library *model.Library) error {
func (m *MockLibraryService) create(ctx context.Context, library *model.Library) error {
if m.Err != nil {
return m.Err
}
@ -63,7 +63,7 @@ func (m *MockLibraryService) Create(ctx context.Context, library *model.Library)
return nil
}
func (m *MockLibraryService) Update(ctx context.Context, library *model.Library) error {
func (m *MockLibraryService) update(ctx context.Context, id int, library *model.Library) error {
if m.Err != nil {
return m.Err
}
@ -76,7 +76,8 @@ func (m *MockLibraryService) Update(ctx context.Context, library *model.Library)
// Find and update in mock data
for i, lib := range m.Libraries {
if lib.ID == library.ID {
if lib.ID == id {
library.ID = id
m.Libraries[i] = *library
return nil
}
@ -84,7 +85,7 @@ func (m *MockLibraryService) Update(ctx context.Context, library *model.Library)
return model.ErrNotFound
}
func (m *MockLibraryService) Delete(ctx context.Context, id int) error {
func (m *MockLibraryService) delete(ctx context.Context, id int) error {
if m.Err != nil {
return m.Err
}
@ -158,7 +159,7 @@ type mockLibraryRepository struct {
}
func (r *mockLibraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
libs, err := r.service.GetAll(r.ctx)
libs, err := r.service.getAll(r.ctx)
if err != nil {
return 0, err
}
@ -170,7 +171,7 @@ func (r *mockLibraryRepository) Read(id string) (interface{}, error) {
if err != nil {
return nil, rest.ErrNotFound
}
lib, err := r.service.Get(r.ctx, idInt)
lib, err := r.service.get(r.ctx, idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
@ -178,7 +179,7 @@ func (r *mockLibraryRepository) Read(id string) (interface{}, error) {
}
func (r *mockLibraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.service.GetAll(r.ctx)
return r.service.getAll(r.ctx)
}
func (r *mockLibraryRepository) EntityName() string {
@ -191,7 +192,7 @@ func (r *mockLibraryRepository) NewInstance() interface{} {
func (r *mockLibraryRepository) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
err := r.service.Create(r.ctx, lib)
err := r.service.create(r.ctx, lib)
if errors.Is(err, model.ErrValidation) {
return "", &rest.ValidationError{Errors: map[string]string{"validation": err.Error()}}
}
@ -202,13 +203,12 @@ func (r *mockLibraryRepository) Save(entity interface{}) (string, error) {
}
func (r *mockLibraryRepository) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {
return &rest.ValidationError{Errors: map[string]string{"id": "invalid library ID format"}}
}
lib.ID = idInt
err = r.service.Update(r.ctx, lib)
lib := entity.(*model.Library)
err = r.service.update(r.ctx, idInt, lib)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@ -223,7 +223,7 @@ func (r *mockLibraryRepository) Delete(id string) error {
if err != nil {
return &rest.ValidationError{Errors: map[string]string{"id": "invalid library ID format"}}
}
err = r.service.Delete(r.ctx, idInt)
err = r.service.delete(r.ctx, idInt)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}