feat(library): implement scanner interface for triggering library scans on create/update

Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	cmd/wire_gen.go
#	cmd/wire_injectors.go

# Conflicts:
#	cmd/wire_gen.go

# Conflicts:
#	cmd/wire_gen.go
#	cmd/wire_injectors.go
This commit is contained in:
Deluan 2025-06-24 23:05:39 -04:00
commit 12562dbfab
6 changed files with 186 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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