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

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