diff --git a/cmd/root.go b/cmd/root.go index df39f50a6..9618b16e6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -110,7 +110,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) { func startServer(ctx context.Context) func() error { return func() error { a := CreateServer() - a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter()) + a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx)) a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx)) a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter()) if conf.Server.LastFM.Enabled { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6584f235..a5bd5707d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -52,13 +52,23 @@ func CreateServer() *server.Server { return serverServer } -func CreateNativeAPIRouter() *nativeapi.Router { +func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) playlists := core.NewPlaylists(dataStore) insights := metrics.GetInstance(dataStore) - library := core.NewLibrary(dataStore) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + library := core.NewLibrary(dataStore, scannerScanner) router := nativeapi.New(dataStore, share, playlists, insights, library) return router } @@ -186,7 +196,7 @@ func getPluginManager() plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner))) func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index e2bc6cd1b..8527b1ecd 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -44,6 +44,7 @@ var allProviders = wire.NewSet( db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), + wire.Bind(new(core.Scanner), new(scanner.Scanner)), ) func CreateDataStore() model.DataStore { @@ -58,7 +59,7 @@ func CreateServer() *server.Server { )) } -func CreateNativeAPIRouter() *nativeapi.Router { +func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { panic(wire.Build( allProviders, )) diff --git a/core/library.go b/core/library.go index f2bca18dd..ce56d7e03 100644 --- a/core/library.go +++ b/core/library.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -18,6 +19,11 @@ import ( "github.com/navidrome/navidrome/model/request" ) +// Scanner defines the interface for triggering library scans +type Scanner interface { + ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) +} + // Library provides business logic for library management and user-library associations type Library interface { GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) @@ -28,12 +34,13 @@ type Library interface { } type libraryService struct { - ds model.DataStore + ds model.DataStore + scanner Scanner } // NewLibrary creates a new Library service -func NewLibrary(ds model.DataStore) Library { - return &libraryService{ds: ds} +func NewLibrary(ds model.DataStore, scanner Scanner) Library { + return &libraryService{ds: ds, scanner: scanner} } // User-library association operations @@ -110,7 +117,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository { LibraryRepository: repo, Repository: repo.(rest.Repository), ds: s.ds, - service: s, + scanner: s.scanner, } return wrapper } @@ -120,7 +127,7 @@ type libraryRepositoryWrapper struct { model.LibraryRepository ctx context.Context ds model.DataStore - service *libraryService + scanner Scanner } func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { @@ -134,6 +141,20 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { return "", r.mapError(err) } + // Trigger scan after successful library creation + if r.scanner != nil { + go func() { + log.Info(r.ctx, "Triggering scan for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) + start := time.Now() + warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library + if err != nil { + log.Error(r.ctx, "Error scanning new library", "libraryID", lib.ID, "name", lib.Name, err) + } else { + log.Info(r.ctx, "Scan completed for new library", "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) + } + }() + } + return strconv.Itoa(lib.ID), nil } @@ -149,13 +170,34 @@ func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols .. return err } - // Verify library exists - if _, err := r.Get(libID); err != nil { + // Get the original library to check if path changed + originalLib, err := r.Get(libID) + if err != nil { return r.mapError(err) } + pathChanged := originalLib.Path != lib.Path + err = r.LibraryRepository.Put(lib) - return r.mapError(err) + if err != nil { + return r.mapError(err) + } + + // Trigger scan if path was updated + if pathChanged && r.scanner != nil { + go func() { + log.Info(r.ctx, "Triggering scan for updated library", "libraryID", lib.ID, "name", lib.Name, "oldPath", originalLib.Path, "newPath", lib.Path) + start := time.Now() + warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for path change + if err != nil { + log.Error(r.ctx, "Error scanning updated library", "libraryID", lib.ID, "name", lib.Name, err) + } else { + log.Info(r.ctx, "Scan completed for updated library", "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) + } + }() + } + + return nil } func (r *libraryRepositoryWrapper) Delete(id string) error { diff --git a/core/library_test.go b/core/library_test.go index 333f63f07..7b0488989 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -18,6 +18,24 @@ import ( . "github.com/onsi/gomega" ) +// mockScanner provides a simple mock implementation of core.Scanner for testing +type mockScanner struct { + ScanCalls []ScanCall +} + +type ScanCall struct { + FullScan bool + Context context.Context +} + +func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) { + m.ScanCalls = append(m.ScanCalls, ScanCall{ + FullScan: fullScan, + Context: ctx, + }) + return []string{}, nil +} + // These tests require the local storage adapter and the taglib extractor to be registered. var _ = Describe("Library Service", func() { var service core.Library @@ -26,6 +44,7 @@ var _ = Describe("Library Service", func() { var userRepo *tests.MockedUserRepo var ctx context.Context var tempDir string + var scanner *mockScanner BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -36,7 +55,9 @@ var _ = Describe("Library Service", func() { ds.MockedLibrary = libraryRepo ds.MockedUser = userRepo - service = core.NewLibrary(ds) + // Create a mock scanner that tracks calls + scanner = &mockScanner{} + service = core.NewLibrary(ds, scanner) ctx = context.Background() // Create a temporary directory for testing valid paths @@ -585,4 +606,100 @@ var _ = Describe("Library Service", func() { }) }) }) + + Describe("Scan Triggering", func() { + var repo rest.Persistable + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + }) + + It("triggers scan when creating a new library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + Expect(err).NotTo(HaveOccurred()) + + // Wait briefly for the goroutine to complete + Eventually(func() int { + return len(scanner.ScanCalls) + }, "1s", "10ms").Should(Equal(1)) + + // Verify scan was called with correct parameters + Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + }) + + It("triggers scan when updating library path", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Create a new temporary directory for the update + newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(newTempDir) }) + + // Update the library with a new path + library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} + err = repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Wait briefly for the goroutine to complete + Eventually(func() int { + return len(scanner.ScanCalls) + }, "1s", "10ms").Should(Equal(1)) + + // Verify scan was called with correct parameters + Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + }) + + It("does not trigger scan when updating library without path change", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Update the library name only (same path) + library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} + err := repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Wait a bit to ensure no scan was triggered + Consistently(func() int { + return len(scanner.ScanCalls) + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("does not trigger scan when library creation fails", func() { + // Try to create library with invalid data (empty name) + library := &model.Library{Path: tempDir} + + _, err := repo.Save(library) + Expect(err).To(HaveOccurred()) + + // Ensure no scan was triggered since creation failed + Consistently(func() int { + return len(scanner.ScanCalls) + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("does not trigger scan when library update fails", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Try to update with invalid data (empty name) + library := &model.Library{ID: 1, Name: "", Path: tempDir} + err := repo.Update("1", library) + Expect(err).To(HaveOccurred()) + + // Ensure no scan was triggered since update failed + Consistently(func() int { + return len(scanner.ScanCalls) + }, "100ms", "10ms").Should(Equal(0)) + }) + }) }) diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 69e047b03..4e6d34582 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -253,7 +254,7 @@ var _ = Describe("Library API", func() { } for _, endpoint := range endpoints { - parts := Split(endpoint, " ") + parts := strings.Split(endpoint, " ") method, path := parts[0], parts[1] req := createAuthenticatedRequest(method, path, nil, userToken) @@ -421,17 +422,3 @@ func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http req.Header.Set("Content-Type", "application/json") return req } - -// Helper function to split strings -func Split(s, sep string) []string { - var result []string - start := 0 - for i, r := range s { - if string(r) == sep { - result = append(result, s[start:i]) - start = i + 1 - } - } - result = append(result, s[start:]) - return result -}