mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-01 16:07:47 +01:00
feat(library): add/remove watchers on demand, not only when server starts
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
8c8ef7836a
commit
ee18a04da9
6 changed files with 361 additions and 57 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue